From 637d3c9aa7ce2664be3ab219cc6f8a073d21e439 Mon Sep 17 00:00:00 2001 From: CamilleLegeron Date: Thu, 21 Mar 2024 09:43:38 +0100 Subject: [PATCH 01/31] Rebase migration over existing 42 --- sandbox/grist/migrations.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index 517b84bd76..ee5688569b 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -1317,3 +1317,17 @@ def migration42(tdset): add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'), add_column('_grist_Triggers', 'options', 'Text'), ]) + +@migration(schema_version=43) +def migration43(tdset): + """ + Add a table for doc api keys. + """ + doc_actions = [ + actions.AddTable("_grist_doc_api_keys", [ + schema.make_column("user", "Ref:users"), + schema.make_column("apiKey", "Text"), + schema.make_column("docId", "Ref:docs"), + ]), + ] + return tdset.apply_doc_actions(doc_actions) From b75e5671e159ee8bab086d7cff40c63d54994b68 Mon Sep 17 00:00:00 2001 From: CamilleLegeron Date: Thu, 28 Mar 2024 14:00:31 +0100 Subject: [PATCH 02/31] create documentSettings api key ui part --- app/client/ui/DocApiKey.ts | 145 ++++++++++++++++++++++++++++ app/client/ui/DocumentSettings.ts | 154 +++++++++++++++++------------- 2 files changed, 231 insertions(+), 68 deletions(-) create mode 100644 app/client/ui/DocApiKey.ts diff --git a/app/client/ui/DocApiKey.ts b/app/client/ui/DocApiKey.ts new file mode 100644 index 0000000000..9d8755f0bb --- /dev/null +++ b/app/client/ui/DocApiKey.ts @@ -0,0 +1,145 @@ +import { makeT } from 'app/client/lib/localization'; +import { basicButton, textButton } from 'app/client/ui2018/buttons'; +import { theme, vars } from 'app/client/ui2018/cssVars'; +import { icon } from 'app/client/ui2018/icons'; +import { confirmModal } from 'app/client/ui2018/modals'; +import { Disposable, dom, IDomArgs, makeTestId, Observable, observable, styled } from 'grainjs'; + +const t = makeT('DocApiKey'); + +interface IWidgetOptions { + docApiKey: Observable; + onDelete: () => Promise; + onCreate: () => Promise; + inputArgs?: IDomArgs; +} + +const testId = makeTestId('test-docapikey-'); + +/** + * DocApiKey component shows an api key with controls to change it. Expects `options.docApiKey` the api + * key and shows it if value is truthy along with a 'Delete' button that triggers the + * `options.onDelete` callback. When `options.docApiKey` is falsy, hides it and show a 'Create' button + * that triggers the `options.onCreate` callback. It is the responsibility of the caller to update + * the `options.docApiKey` to its new value. + */ +export class DocApiKey extends Disposable { + // TODO : user actually logged in, and value if the user is owner of the document. + private _docApiKey: Observable; + private _onDeleteCB: () => Promise; + private _onCreateCB: () => Promise; + private _inputArgs: IDomArgs; + private _loading = observable(false); + private _isHidden: Observable = Observable.create(this, true); + + constructor(options: IWidgetOptions) { + super(); + this._docApiKey = options.docApiKey; + this._onDeleteCB = options.onDelete; + this._onCreateCB = options.onCreate; + this._inputArgs = options.inputArgs ?? []; + } + + public buildDom() { + return dom('div', testId('container'), dom.style('position', 'relative'), + dom.maybe(this._docApiKey, (docApiKey) => dom('div', + cssRow( + cssInput( + { + readonly: true, + value: this._docApiKey.get(), + }, + dom.attr('type', (use) => use(this._isHidden) ? 'password' : 'text'), + testId('key'), + {title: t("Click to show")}, + dom.on('click', (_ev, el) => { + this._isHidden.set(false); + setTimeout(() => el.select(), 0); + }), + dom.on('blur', (ev) => { + // Hide the key when it is no longer selected. + if (ev.target !== document.activeElement) { this._isHidden.set(true); } + }), + this._inputArgs + ), + cssTextBtn( + cssTextBtnIcon('Remove'), t("Remove"), + dom.on('click', () => this._showRemoveKeyModal()), + testId('delete'), + dom.boolAttr('disabled', (use) => use(this._loading)) // or is not owner + ), + ), + description('This doc API key can be used to access this document via the API. \ +Don’t share this API key.', testId('description')), + )), + dom.maybe((use) => !use(this._docApiKey), () => [ + basicButton(t("Create"), dom.on('click', () => this._onCreate()), testId('create'), + dom.boolAttr('disabled', this._loading)), + description(t("By generating a doc API key, you will be able to \ +make API calls for this particular document."), testId('description')), + ]), + ); + } + + // Switch the `_loading` flag to `true` and later, once promise resolves, switch it back to + // `false`. + private async _switchLoadingFlag(promise: Promise) { + this._loading.set(true); + try { + await promise; + } finally { + this._loading.set(false); + } + } + + private _onDelete(): Promise { + return this._switchLoadingFlag(this._onDeleteCB()); + } + + private _onCreate(): Promise { + return this._switchLoadingFlag(this._onCreateCB()); + } + + private _showRemoveKeyModal(): void { + confirmModal( + t("Remove API Key"), t("Remove"), + () => this._onDelete(), + { + explanation: t( + "You're about to delete a doc API key. This will cause all future requests \ +using this doc API key to be rejected. Do you still want to delete?" + ), + } + ); + } +} + +const description = styled('div', ` + margin-top: 8px; + color: ${theme.lightText}; + font-size: ${vars.mediumFontSize}; +`); + +const cssInput = styled('input', ` + background-color: transparent; + color: ${theme.inputFg}; + border: 1px solid ${theme.inputBorder}; + padding: 4px; + border-radius: 3px; + outline: none; + flex: 1 0 0; +`); + +const cssRow = styled('div', ` + display: flex; +`); + +const cssTextBtn = styled(textButton, ` + text-align: left; + width: 90px; + margin-left: 16px; +`); + +const cssTextBtnIcon = styled(icon, ` + margin: 0 4px 2px 0; +`); diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index b2c9ba287b..499be3069f 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -16,10 +16,13 @@ import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss'; import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox'; -import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; +import {docListHeader} from 'app/client/ui/DocMenuCss'; +import {DocApiKey} from 'app/client/ui/DocApiKey'; +import {primaryButtonLink} from 'app/client/ui2018/buttons'; +import {colors, mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; import {select} from 'app/client/ui2018/menus'; import {confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal} from 'app/client/ui2018/modals'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; @@ -38,6 +41,7 @@ const testId = makeTestId('test-settings-'); export class DocSettingsPage extends Disposable { private _docInfo = this._gristDoc.docInfo; + private _docApiKey = Observable.create(this, ''); private _timezone = this._docInfo.timezone; private _locale: KoSaveableObservable = this._docInfo.documentSettingsJson.prop('locale'); private _currency: KoSaveableObservable = this._docInfo.documentSettingsJson.prop('currency'); @@ -186,7 +190,12 @@ export class DocSettingsPage extends Disposable { href: getApiConsoleLink(docPageModel), }), }), - + dom.create(DocApiKey, { + docApiKey: this._docApiKey, + onCreate: () => this._createDocApiKey(), + onDelete: () => this._deleteDocApiKey(), + inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px. + }), dom.create(AdminSectionItem, { id: 'webhooks', name: t('Webhooks'), @@ -296,6 +305,15 @@ export class DocSettingsPage extends Disposable { await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); } } + + private async _createDocApiKey() { + // this._docApiKey.set(await this._appModel.api.createApiKey()); + } + + private async _deleteDocApiKey() { + // await this._appModel.api.deleteApiKey(); + this._docApiKey.set(''); + } } @@ -344,64 +362,64 @@ function buildLocaleSelect( } const cssContainer = styled('div', ` - overflow-y: auto; - position: relative; - height: 100%; - padding: 32px 64px 24px 64px; - color: ${theme.text}; - @media ${mediaSmall} { - & { - padding: 32px 24px 24px 24px; - } - } +overflow-y: auto; +position: relative; +height: 100%; +padding: 32px 64px 24px 64px; +color: ${theme.text}; +@media ${mediaSmall} { +& { +padding: 32px 24px 24px 24px; +} +} `); const cssCopyButton = styled('div', ` - position: absolute; - display: flex; - align-items: center; - justify-content: center; - height: 100%; - width: 24px; - right: 0; - top: 0; - --icon-color: ${theme.lightText}; - &:hover { - --icon-color: ${colors.lightGreen}; - } +position: absolute; +display: flex; +align-items: center; +justify-content: center; +height: 100%; +width: 24px; +right: 0; +top: 0; +--icon-color: ${theme.lightText}; +&:hover { +--icon-color: ${colors.lightGreen}; +} `); const cssIcon = styled(icon, ` `); const cssInput = styled('div', ` - border: none; - outline: none; - background: transparent; - width: 100%; - min-width: 180px; - height: 100%; - padding: 5px; - padding-right: 20px; - overflow: hidden; - text-overflow: ellipsis; +border: none; +outline: none; +background: transparent; +width: 100%; +min-width: 180px; +height: 100%; +padding: 5px; +padding-right: 20px; +overflow: hidden; +text-overflow: ellipsis; `); const cssHoverWrapper = styled('div', ` - max-width: 280px; - text-overflow: ellipsis; - overflow: hidden; - text-wrap: nowrap; - display: inline-block; - cursor: pointer; - transition: all 0.05s; - border-radius: 4px; - border-color: ${theme.inputBorder}; - border-style: solid; - border-width: 1px; - height: 30px; - align-items: center; - position: relative; +max-width: 280px; +text-overflow: ellipsis; +overflow: hidden; +text-wrap: nowrap; +display: inline-block; +cursor: pointer; +transition: all 0.05s; +border-radius: 4px; +border-color: ${theme.inputBorder}; +border-style: solid; +border-width: 1px; +height: 30px; +align-items: center; +position: relative; `); // This matches the style used in showProfileModal in app/client/ui/AccountWidget. @@ -414,7 +432,7 @@ export function getSupportedEngineChoices(): EngineCode[] { } const cssSelect = styled(select, ` - min-width: 170px; /* to match the width of the timezone picker */ +min-width: 170px; /* to match the width of the timezone picker */ `); const TOOLTIP_KEY = 'copy-on-settings'; @@ -476,23 +494,23 @@ enum Option { // A version that is not underlined, and on hover mouse pointer indicates that copy is available const cssCopyLink = styled(cssLink, ` - word-wrap: break-word; - &:hover { - border-radius: 4px; - text-decoration: none; - background: ${theme.lightHover}; - outline-color: ${theme.linkHover}; - outline-offset: 1px; - } +word-wrap: break-word; +&:hover { +border-radius: 4px; +text-decoration: none; +background: ${theme.lightHover}; +outline-color: ${theme.linkHover}; +outline-offset: 1px; +} `); const cssAutoComplete = ` - width: 172px; - cursor: pointer; - & input { - text-overflow: ellipsis; - padding-right: 24px; - } +width: 172px; +cursor: pointer; +& input { +text-overflow: ellipsis; +padding-right: 24px; +} `; const cssTZAutoComplete = styled(buildTZAutocomplete, cssAutoComplete); @@ -500,12 +518,12 @@ const cssCurrencyPicker = styled(buildCurrencyPicker, cssAutoComplete); const cssLocalePicker = styled(buildLocaleSelect, cssAutoComplete); const cssWrap = styled('p', ` - overflow-wrap: anywhere; - & * { - word-break: break-all; - } +overflow-wrap: anywhere; +& * { +word-break: break-all; +} `); const cssRedText = styled('span', ` - color: ${theme.errorText}; +color: ${theme.errorText}; `); From 554cf1da09fc8550cd61be4a09c34c25ec79bd9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 27 Jun 2024 10:27:19 +0200 Subject: [PATCH 03/31] WIP --- app/gen-server/ApiServer.ts | 21 +++++++++++ app/gen-server/entity/Document.ts | 3 ++ app/gen-server/lib/homedb/HomeDBManager.ts | 43 ++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index b927d62f4a..7715884d1d 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -324,6 +324,27 @@ export class ApiServer { return sendOkReply(req, res); })); + // GET /api/docs/:docId/apiKey + this._app.get('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { + const apiKey = this._dbManager.getDocApiKey(req.params.docId); + res.status(200).json(`${apiKey}`); + })); + + // POST /api/docs/:docId/apiKey + this._app.post('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { + res.status(201).json('Created api key') + })); + + // PUT /api/docs/:docId/apiKey + this._app.put('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { + res.status(200).json('UPDATED api key') + })); + + // DELETE /api/docs/:docId/apiKey + this._app.delete('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { + res.status(200).json('delete api key') + })); + // PATCH /api/orgs/:oid/access // Update the specified org acl rules. this._app.patch('/api/orgs/:oid/access', expressWrap(async (req, res) => { diff --git a/app/gen-server/entity/Document.ts b/app/gen-server/entity/Document.ts index 44e678ff61..378b7a372e 100644 --- a/app/gen-server/entity/Document.ts +++ b/app/gen-server/entity/Document.ts @@ -92,6 +92,9 @@ export class Document extends Resource { @Column({name: 'type', type: 'text', nullable: true}) public type: DocumentType|null; + //@Column({name: 'api_key', type: 'text', nullable: true}) + //public apiKey: string|null; + public checkProperties(props: any): props is Partial { return super.checkProperties(props, documentPropertyKeys); } diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index a4f897211f..93c26421e9 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2497,6 +2497,49 @@ export class HomeDBManager extends EventEmitter { .getOne() || undefined; } + // TODO filter by linkId + public async getDocApiKey(docId: string): Promise { + return await this._connection.createQueryBuilder() + .select('key') + .from(Share,'shares') + .where('docId = :docId', {docId}) + .getOne() || undefined; + } + + public async createDocApiKey(docId: string, share: ShareInfo) { + const key = makeId(); + const apiKey_options = {...JSON.parse(share.options), "apikey": true} + return await this._connection.createQueryBuilder() + .insert() + .setParameter('options', share.options) + .into(Share) + .values({ + linkId: share.linkId, + docId, + options: apiKey_options, + key, + }) + .execute(); + } + + // TODO test if exists docapikey yet + public async updateDocApiKey(docId: string, apiKey: string) { + return await this._connection.createQueryBuilder() + .update(Share) + .set({key: apiKey}) + .where('docId = :docId', {docId}) + .execute() || undefined; + } + + public async deleteDocApiKey(docId: string, apiKey: string) { + return await this.connection.createQueryBuilder() + .delete() + .from('shares') + .where('docId = :docId', {docId}) + .where('key = :apiKey', {apiKey}) + .execute() || undefined; + } + public getAnonymousUser() { return this._usersManager.getAnonymousUser(); } From 2ff7e67257d5f2bd734a12207721eb97e8d33067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 27 Jun 2024 10:28:30 +0200 Subject: [PATCH 04/31] wip --- app/client/ui/DocumentSettings.ts | 6 +++--- app/gen-server/ApiServer.ts | 6 +++--- app/gen-server/lib/homedb/HomeDBManager.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index 499be3069f..e4d818047f 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -19,10 +19,10 @@ import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/ch import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; -import {docListHeader} from 'app/client/ui/DocMenuCss'; +//import {docListHeader} from 'app/client/ui/DocMenuCss'; import {DocApiKey} from 'app/client/ui/DocApiKey'; -import {primaryButtonLink} from 'app/client/ui2018/buttons'; -import {colors, mediaSmall, theme, vars} from 'app/client/ui2018/cssVars'; +//import {primaryButtonLink} from 'app/client/ui2018/buttons'; +import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {select} from 'app/client/ui2018/menus'; import {confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal} from 'app/client/ui2018/modals'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 7715884d1d..8c82fff51c 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -332,17 +332,17 @@ export class ApiServer { // POST /api/docs/:docId/apiKey this._app.post('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - res.status(201).json('Created api key') + res.status(201).json('Created api key'); })); // PUT /api/docs/:docId/apiKey this._app.put('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - res.status(200).json('UPDATED api key') + res.status(200).json('UPDATED api key'); })); // DELETE /api/docs/:docId/apiKey this._app.delete('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - res.status(200).json('delete api key') + res.status(200).json('delete api key'); })); // PATCH /api/orgs/:oid/access diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index 93c26421e9..6652ea964f 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2501,14 +2501,14 @@ export class HomeDBManager extends EventEmitter { public async getDocApiKey(docId: string): Promise { return await this._connection.createQueryBuilder() .select('key') - .from(Share,'shares') + .from(Share, 'shares') .where('docId = :docId', {docId}) .getOne() || undefined; } public async createDocApiKey(docId: string, share: ShareInfo) { const key = makeId(); - const apiKey_options = {...JSON.parse(share.options), "apikey": true} + const apiKey_options = {...JSON.parse(share.options), "apikey": true}; return await this._connection.createQueryBuilder() .insert() .setParameter('options', share.options) From cfc2c96ccfce209e59fa810d72b8020fab910611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 1 Jul 2024 12:36:08 +0200 Subject: [PATCH 05/31] Remove uneeded migration --- sandbox/grist/migrations.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/sandbox/grist/migrations.py b/sandbox/grist/migrations.py index ee5688569b..517b84bd76 100644 --- a/sandbox/grist/migrations.py +++ b/sandbox/grist/migrations.py @@ -1317,17 +1317,3 @@ def migration42(tdset): add_column('_grist_Triggers', 'watchedColRefList', 'RefList:_grist_Tables_column'), add_column('_grist_Triggers', 'options', 'Text'), ]) - -@migration(schema_version=43) -def migration43(tdset): - """ - Add a table for doc api keys. - """ - doc_actions = [ - actions.AddTable("_grist_doc_api_keys", [ - schema.make_column("user", "Ref:users"), - schema.make_column("apiKey", "Text"), - schema.make_column("docId", "Ref:docs"), - ]), - ] - return tdset.apply_doc_actions(doc_actions) From 14f97f5982382f4e63b084be5eb1f6d5bf8d1368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 1 Jul 2024 13:58:38 +0200 Subject: [PATCH 06/31] fix: indent --- app/client/ui/DocumentSettings.ts | 136 +++++++++++++++--------------- 1 file changed, 67 insertions(+), 69 deletions(-) diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index e4d818047f..6e1c2acd13 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -16,13 +16,11 @@ import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss'; import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox'; +import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; -//import {docListHeader} from 'app/client/ui/DocMenuCss'; import {DocApiKey} from 'app/client/ui/DocApiKey'; -//import {primaryButtonLink} from 'app/client/ui2018/buttons'; -import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {select} from 'app/client/ui2018/menus'; import {confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal} from 'app/client/ui2018/modals'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; @@ -362,64 +360,64 @@ function buildLocaleSelect( } const cssContainer = styled('div', ` -overflow-y: auto; -position: relative; -height: 100%; -padding: 32px 64px 24px 64px; -color: ${theme.text}; -@media ${mediaSmall} { -& { -padding: 32px 24px 24px 24px; -} -} + overflow-y: auto; + position: relative; + height: 100%; + padding: 32px 64px 24px 64px; + color: ${theme.text}; + @media ${mediaSmall} { + & { + padding: 32px 24px 24px 24px; + } + } `); const cssCopyButton = styled('div', ` -position: absolute; -display: flex; -align-items: center; -justify-content: center; -height: 100%; -width: 24px; -right: 0; -top: 0; ---icon-color: ${theme.lightText}; -&:hover { ---icon-color: ${colors.lightGreen}; -} + position: absolute; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 24px; + right: 0; + top: 0; + --icon-color: ${theme.lightText}; + &:hover { + --icon-color: ${colors.lightGreen}; + } `); const cssIcon = styled(icon, ` `); const cssInput = styled('div', ` -border: none; -outline: none; -background: transparent; -width: 100%; -min-width: 180px; -height: 100%; -padding: 5px; -padding-right: 20px; -overflow: hidden; -text-overflow: ellipsis; + border: none; + outline: none; + background: transparent; + width: 100%; + min-width: 180px; + height: 100%; + padding: 5px; + padding-right: 20px; + overflow: hidden; + text-overflow: ellipsis; `); const cssHoverWrapper = styled('div', ` -max-width: 280px; -text-overflow: ellipsis; -overflow: hidden; -text-wrap: nowrap; -display: inline-block; -cursor: pointer; -transition: all 0.05s; -border-radius: 4px; -border-color: ${theme.inputBorder}; -border-style: solid; -border-width: 1px; -height: 30px; -align-items: center; -position: relative; + max-width: 280px; + text-overflow: ellipsis; + overflow: hidden; + text-wrap: nowrap; + display: inline-block; + cursor: pointer; + transition: all 0.05s; + border-radius: 4px; + border-color: ${theme.inputBorder}; + border-style: solid; + border-width: 1px; + height: 30px; + align-items: center; + position: relative; `); // This matches the style used in showProfileModal in app/client/ui/AccountWidget. @@ -432,7 +430,7 @@ export function getSupportedEngineChoices(): EngineCode[] { } const cssSelect = styled(select, ` -min-width: 170px; /* to match the width of the timezone picker */ + min-width: 170px; /* to match the width of the timezone picker */ `); const TOOLTIP_KEY = 'copy-on-settings'; @@ -494,23 +492,23 @@ enum Option { // A version that is not underlined, and on hover mouse pointer indicates that copy is available const cssCopyLink = styled(cssLink, ` -word-wrap: break-word; -&:hover { -border-radius: 4px; -text-decoration: none; -background: ${theme.lightHover}; -outline-color: ${theme.linkHover}; -outline-offset: 1px; -} + word-wrap: break-word; + &:hover { + border-radius: 4px; + text-decoration: none; + background: ${theme.lightHover}; + outline-color: ${theme.linkHover}; + outline-offset: 1px; + } `); const cssAutoComplete = ` -width: 172px; -cursor: pointer; -& input { -text-overflow: ellipsis; -padding-right: 24px; -} + width: 172px; + cursor: pointer; + & input { + text-overflow: ellipsis; + padding-right: 24px; + } `; const cssTZAutoComplete = styled(buildTZAutocomplete, cssAutoComplete); @@ -518,12 +516,12 @@ const cssCurrencyPicker = styled(buildCurrencyPicker, cssAutoComplete); const cssLocalePicker = styled(buildLocaleSelect, cssAutoComplete); const cssWrap = styled('p', ` -overflow-wrap: anywhere; -& * { -word-break: break-all; -} + overflow-wrap: anywhere; + & * { + word-break: break-all; + } `); const cssRedText = styled('span', ` -color: ${theme.errorText}; + color: ${theme.errorText}; `); From 850363c3f4aa598c547667512889702b582a00c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 1 Jul 2024 14:00:32 +0200 Subject: [PATCH 07/31] chore: remove unused key --- app/gen-server/entity/Document.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/gen-server/entity/Document.ts b/app/gen-server/entity/Document.ts index 378b7a372e..44e678ff61 100644 --- a/app/gen-server/entity/Document.ts +++ b/app/gen-server/entity/Document.ts @@ -92,9 +92,6 @@ export class Document extends Resource { @Column({name: 'type', type: 'text', nullable: true}) public type: DocumentType|null; - //@Column({name: 'api_key', type: 'text', nullable: true}) - //public apiKey: string|null; - public checkProperties(props: any): props is Partial { return super.checkProperties(props, documentPropertyKeys); } From e4880d4bbb13e9c22cdb6358878b71bf5aea5085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 1 Jul 2024 17:19:05 +0200 Subject: [PATCH 08/31] WIP post apikey --- app/gen-server/ApiServer.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 8c82fff51c..7be9fb085c 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -332,6 +332,15 @@ export class ApiServer { // POST /api/docs/:docId/apiKey this._app.post('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { + const options = { + apikey: true, + access: "Editor" + } + const shareInfo = { + linkId: `DEV-KEY-${Date.now()}`, + options: JSON.stringify(options), + }; + await this._dbManager.createDocApiKey(req.params.docId, shareInfo); res.status(201).json('Created api key'); })); From e97f75ba4d15fb1e8c6707380b9c2d5d9ba28187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Tue, 2 Jul 2024 19:22:56 +0200 Subject: [PATCH 09/31] WIP: endpoints TODO: tests --- app/gen-server/ApiServer.ts | 44 +++++++++++--------- app/gen-server/lib/homedb/HomeDBManager.ts | 47 ++++++++++++++++++---- 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 7be9fb085c..c8fef1a1aa 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -324,34 +324,40 @@ export class ApiServer { return sendOkReply(req, res); })); - // GET /api/docs/:docId/apiKey - this._app.get('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - const apiKey = this._dbManager.getDocApiKey(req.params.docId); + // GET /api/docs/:docId/apiKey/:LinkId + this._app.get('/api/docs/:docId/apiKey/:linkId', expressWrap(async (req, res) => { + const apiKey = this._dbManager.getDocApiKeyByLinkId(req.params.docId, req.params.linkId); res.status(200).json(`${apiKey}`); })); // POST /api/docs/:docId/apiKey this._app.post('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - const options = { - apikey: true, - access: "Editor" - } - const shareInfo = { - linkId: `DEV-KEY-${Date.now()}`, - options: JSON.stringify(options), - }; - await this._dbManager.createDocApiKey(req.params.docId, shareInfo); - res.status(201).json('Created api key'); + await this._dbManager.createDocApiKey(req.params.docId, req.body); + res.status(201).json(`CREATED api key ${req.body.linkId}`); + })); + + // PUT /api/docs/:docId/apiKey/:linkId + this._app.put('/api/docs/:docId/apiKey/:linkId', expressWrap(async (req, res) => { + await this._dbManager.updateDocApiKeyByLinkId(req.params.docId, req.params.linkId, req.body.options); + res.status(200).json(`UPDATED api key ${req.params.linkId}`); + })); + + // DELETE /api/docs/:docId/apiKey/:linkId + this._app.delete('/api/docs/:docId/apiKey/:linkId', expressWrap(async (req, res) => { + await this._dbManager.deleteDocApiKeyByLinkId(req.params.docId, req.params.linkId); + res.status(200).json(`DELETED api key ${req.params.linkId}`); })); - // PUT /api/docs/:docId/apiKey - this._app.put('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - res.status(200).json('UPDATED api key'); + // GET /api/docs/:docId/apiKeys + this._app.get('/api/docs/:docId/apiKeys', expressWrap(async (req, res) => { + const apiKeys = this._dbManager.getDocApiKeys(req.params.docId); + res.status(200).json(`${apiKeys}`); })); - // DELETE /api/docs/:docId/apiKey - this._app.delete('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - res.status(200).json('delete api key'); + // DELETE /api/docs/:docId/apiKeys + this._app.delete('/api/docs/:docId/apiKeys', expressWrap(async (req, res) => { + await this._dbManager.deleteDocApiKeys(req.params.docId); + res.status(200).json(`DELETED all doc api keys for document ${req.params.docId}`); })); // PATCH /api/orgs/:oid/access diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index 6652ea964f..abcac934f7 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2497,12 +2497,12 @@ export class HomeDBManager extends EventEmitter { .getOne() || undefined; } - // TODO filter by linkId - public async getDocApiKey(docId: string): Promise { + // can be factorized with getShareByLinkId + public async getDocApiKeyByLinkId(docId: string, linkId: string): Promise { return await this._connection.createQueryBuilder() .select('key') .from(Share, 'shares') - .where('docId = :docId', {docId}) + .where('docId = :docId and linkId = :linkId', {docId, linkId}) .getOne() || undefined; } @@ -2522,21 +2522,52 @@ export class HomeDBManager extends EventEmitter { .execute(); } - // TODO test if exists docapikey yet - public async updateDocApiKey(docId: string, apiKey: string) { + // in parameters linkId is the linkId in db in case of update of this id in the share + public async updateDocApiKeyByLinkId(docId: string, linkId: string, share: ShareInfo) { + return await this._connection.createQueryBuilder() + .update(Share) + .set(share) + .where('docId = :docId and linkId = :linkId', {docId, linkId}) + .execute() || undefined; + } + + public async updateDocApiKeyByKey(docId: string, apiKey: string, share: ShareInfo) { return await this._connection.createQueryBuilder() .update(Share) - .set({key: apiKey}) + .set(share) + .where('docId = :docId and key = :apiKey', {docId, apiKey}) + .execute() || undefined; + } + + public async deleteDocApiKeyByKey(docId: string, apiKey: string) { + return await this.connection.createQueryBuilder() + .delete() + .from('shares') + .where('docId = :docId and key = :apiKey', {docId, apiKey}) + .execute() || undefined; + } + + public async getDocApiKeys(docId: string): Promise { + return await this._connection.createQueryBuilder() + .select('key') + .from(Share, 'shares') .where('docId = :docId', {docId}) + .getMany() || undefined; + } + + public async deleteDocApiKeyByLinkId(docId: string, linkId: string) { + return await this.connection.createQueryBuilder() + .delete() + .from('shares') + .where('docId = :docId and linkId = :linkId', {docId, linkId}) .execute() || undefined; } - public async deleteDocApiKey(docId: string, apiKey: string) { + public async deleteDocApiKeys(docId: string) { return await this.connection.createQueryBuilder() .delete() .from('shares') .where('docId = :docId', {docId}) - .where('key = :apiKey', {apiKey}) .execute() || undefined; } From 94386cd6b9b59512e709df628fd1c2ccae0c789a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Wed, 3 Jul 2024 18:15:37 +0200 Subject: [PATCH 10/31] wip doc api key --- app/gen-server/ApiServer.ts | 57 +++++++++++++--------- app/gen-server/lib/homedb/HomeDBManager.ts | 21 ++++---- test/gen-server/ApiServer.ts | 45 +++++++++++++++++ 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index c8fef1a1aa..5540baed38 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -324,40 +324,49 @@ export class ApiServer { return sendOkReply(req, res); })); - // GET /api/docs/:docId/apiKey/:LinkId - this._app.get('/api/docs/:docId/apiKey/:linkId', expressWrap(async (req, res) => { - const apiKey = this._dbManager.getDocApiKeyByLinkId(req.params.docId, req.params.linkId); - res.status(200).json(`${apiKey}`); + // POST /api/docs/:did/apiKey + this._app.post('/api/docs/:did/apiKey', expressWrap(async (req, res) => { + const did = req.params.did; + const query = await this._dbManager.createDocApiKey(did, req.body); + return sendOkReply(req, res, query); })); - // POST /api/docs/:docId/apiKey - this._app.post('/api/docs/:docId/apiKey', expressWrap(async (req, res) => { - await this._dbManager.createDocApiKey(req.params.docId, req.body); - res.status(201).json(`CREATED api key ${req.body.linkId}`); + // GET /api/docs/:did/apiKey/:LinkId + this._app.get('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { + const did = req.params.did; + const linkId = req.params.linkId; + const query = await this._dbManager.getDocApiKeyByLinkId(did, linkId); + return query ? res.status(200).send(query.key) : res.status(400); })); - // PUT /api/docs/:docId/apiKey/:linkId - this._app.put('/api/docs/:docId/apiKey/:linkId', expressWrap(async (req, res) => { - await this._dbManager.updateDocApiKeyByLinkId(req.params.docId, req.params.linkId, req.body.options); - res.status(200).json(`UPDATED api key ${req.params.linkId}`); + // PATCH /api/docs/:did/apiKey/:linkId + this._app.patch('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { + const did = req.params.did; + const linkId = req.params.linkId; + const query = await this._dbManager.updateDocApiKeyByLinkId(did, linkId, req.body.options); + return sendOkReply(req, res, query); })); - // DELETE /api/docs/:docId/apiKey/:linkId - this._app.delete('/api/docs/:docId/apiKey/:linkId', expressWrap(async (req, res) => { - await this._dbManager.deleteDocApiKeyByLinkId(req.params.docId, req.params.linkId); - res.status(200).json(`DELETED api key ${req.params.linkId}`); + // DELETE /api/docs/:did/apiKey/:linkId + this._app.delete('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { + const did = req.params.did; + const linkId = req.params.linkId; + const query = await this._dbManager.deleteDocApiKeyByLinkId(did, linkId); + return sendOkReply(req, res, query); })); - // GET /api/docs/:docId/apiKeys - this._app.get('/api/docs/:docId/apiKeys', expressWrap(async (req, res) => { - const apiKeys = this._dbManager.getDocApiKeys(req.params.docId); - res.status(200).json(`${apiKeys}`); + // GET /api/docs/:did/apiKeys + this._app.get('/api/docs/:did/apiKeys', expressWrap(async (req, res) => { + const did = req.params.did; + const query = await this._dbManager.getDocApiKeys(did); + return sendOkReply(req, res, query); })); - // DELETE /api/docs/:docId/apiKeys - this._app.delete('/api/docs/:docId/apiKeys', expressWrap(async (req, res) => { - await this._dbManager.deleteDocApiKeys(req.params.docId); - res.status(200).json(`DELETED all doc api keys for document ${req.params.docId}`); + // DELETE /api/docs/:did/apiKeys + this._app.delete('/api/docs/:did/apiKeys', expressWrap(async (req, res) => { + const did = req.params.did; + const query = await this._dbManager.deleteDocApiKeys(did); + return sendOkReply(req, res, query); })); // PATCH /api/orgs/:oid/access diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index abcac934f7..0e0b0fccd1 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2500,9 +2500,10 @@ export class HomeDBManager extends EventEmitter { // can be factorized with getShareByLinkId public async getDocApiKeyByLinkId(docId: string, linkId: string): Promise { return await this._connection.createQueryBuilder() - .select('key') + .select('shares') .from(Share, 'shares') - .where('docId = :docId and linkId = :linkId', {docId, linkId}) + .where('shares.doc_id = :docId', {docId}) + .andWhere('shares.link_id = :linkId', {linkId}) .getOne() || undefined; } @@ -2515,11 +2516,11 @@ export class HomeDBManager extends EventEmitter { .into(Share) .values({ linkId: share.linkId, - docId, + docId: docId, options: apiKey_options, key, }) - .execute(); + .execute() || undefined; } // in parameters linkId is the linkId in db in case of update of this id in the share @@ -2527,7 +2528,7 @@ export class HomeDBManager extends EventEmitter { return await this._connection.createQueryBuilder() .update(Share) .set(share) - .where('docId = :docId and linkId = :linkId', {docId, linkId}) + .where('doc_id = :docId and link_id = :linkId', {docId, linkId}) .execute() || undefined; } @@ -2535,7 +2536,7 @@ export class HomeDBManager extends EventEmitter { return await this._connection.createQueryBuilder() .update(Share) .set(share) - .where('docId = :docId and key = :apiKey', {docId, apiKey}) + .where('doc_id = :docId and key = :apiKey', {docId, apiKey}) .execute() || undefined; } @@ -2543,7 +2544,7 @@ export class HomeDBManager extends EventEmitter { return await this.connection.createQueryBuilder() .delete() .from('shares') - .where('docId = :docId and key = :apiKey', {docId, apiKey}) + .where('doc_id = :docId and key = :apiKey', {docId, apiKey}) .execute() || undefined; } @@ -2551,7 +2552,7 @@ export class HomeDBManager extends EventEmitter { return await this._connection.createQueryBuilder() .select('key') .from(Share, 'shares') - .where('docId = :docId', {docId}) + .where('doc_id = :docId', {docId}) .getMany() || undefined; } @@ -2559,7 +2560,7 @@ export class HomeDBManager extends EventEmitter { return await this.connection.createQueryBuilder() .delete() .from('shares') - .where('docId = :docId and linkId = :linkId', {docId, linkId}) + .where('doc_id = :docId and link_id = :linkId', {docId, linkId}) .execute() || undefined; } @@ -2567,7 +2568,7 @@ export class HomeDBManager extends EventEmitter { return await this.connection.createQueryBuilder() .delete() .from('shares') - .where('docId = :docId', {docId}) + .where('doc_id = :docId', {docId}) .execute() || undefined; } diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index 39aba8c35d..09269951ed 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1469,6 +1469,51 @@ describe('ApiServer', function() { assert.equal(resp.status, 403); }); + it('POST /api/docs/{did}/apikey/{linkId} is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const body = {"linkId": "Peace-And-Tranquility-2-Earth", "options":JSON.stringify(options)} + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, chimpy); + assert.equal(fetchResp.status, 200); + console.log(";;;;;;;;;;;", resp); + console.log("!!!!!!!!!!!", fetchResp.data); + //assert.deepEqual(fetchResp.data[0], { + // key: resp.key, + //}); + }); + + it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 666); + }); + + it('PUT /api/docs/{did}/apikey/{linkId} is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 666); + }); + + it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 666); + }); + + it('GET /api/docs/{did}/apikeys is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 666); + }); + + it('DELETE /api/docs/{did}/apikeys is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const resp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy); + assert.equal(resp.status, 200); + }); + it('GET /api/zig is a 404', async function() { const resp = await axios.get(`${homeUrl}/api/zig`, chimpy); assert.equal(resp.status, 404); From b597a3d04fb6fbf341ed1b787b72d1e77b67511c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Wed, 3 Jul 2024 18:16:36 +0200 Subject: [PATCH 11/31] wip doc api key --- test/gen-server/ApiServer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index 09269951ed..dd49d79c0f 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1472,7 +1472,7 @@ describe('ApiServer', function() { it('POST /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; - const body = {"linkId": "Peace-And-Tranquility-2-Earth", "options":JSON.stringify(options)} + const body = {"linkId": "Peace-And-Tranquility-2-Earth", "options":JSON.stringify(options)}; const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); assert.equal(resp.status, 200); const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, chimpy); From 5a631cb22bd50ff0089b13d3efb9e7288bc44e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 4 Jul 2024 18:02:12 +0200 Subject: [PATCH 12/31] wip tests --- app/common/ShareOptions.ts | 2 + app/gen-server/ApiServer.ts | 8 +-- app/gen-server/lib/homedb/HomeDBManager.ts | 3 +- test/gen-server/ApiServer.ts | 82 +++++++++++++++------- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/app/common/ShareOptions.ts b/app/common/ShareOptions.ts index b07c9924bc..5f8163f979 100644 --- a/app/common/ShareOptions.ts +++ b/app/common/ShareOptions.ts @@ -19,4 +19,6 @@ export interface ShareOptions { // Half-baked, just here to exercise an aspect of homedb // syncing. access?: 'editors' | 'viewers'; + + apikey?: true; } diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 5540baed38..f1ce3da592 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -327,8 +327,8 @@ export class ApiServer { // POST /api/docs/:did/apiKey this._app.post('/api/docs/:did/apiKey', expressWrap(async (req, res) => { const did = req.params.did; - const query = await this._dbManager.createDocApiKey(did, req.body); - return sendOkReply(req, res, query); + const key = await this._dbManager.createDocApiKey(did, req.body); + return res.status(200).send(key); })); // GET /api/docs/:did/apiKey/:LinkId @@ -336,14 +336,14 @@ export class ApiServer { const did = req.params.did; const linkId = req.params.linkId; const query = await this._dbManager.getDocApiKeyByLinkId(did, linkId); - return query ? res.status(200).send(query.key) : res.status(400); + return query ? res.status(200).send(query) : res.status(404).send(); })); // PATCH /api/docs/:did/apiKey/:linkId this._app.patch('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { const did = req.params.did; const linkId = req.params.linkId; - const query = await this._dbManager.updateDocApiKeyByLinkId(did, linkId, req.body.options); + const query = await this._dbManager.updateDocApiKeyByLinkId(did, linkId, req.body); return sendOkReply(req, res, query); })); diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index 0e0b0fccd1..9f2cb40ec3 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2510,7 +2510,7 @@ export class HomeDBManager extends EventEmitter { public async createDocApiKey(docId: string, share: ShareInfo) { const key = makeId(); const apiKey_options = {...JSON.parse(share.options), "apikey": true}; - return await this._connection.createQueryBuilder() + const query = await this._connection.createQueryBuilder() .insert() .setParameter('options', share.options) .into(Share) @@ -2521,6 +2521,7 @@ export class HomeDBManager extends EventEmitter { key, }) .execute() || undefined; + return query ? key : query; } // in parameters linkId is the linkId in db in case of update of this id in the share diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index dd49d79c0f..180c2f72e5 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1469,50 +1469,80 @@ describe('ApiServer', function() { assert.equal(resp.status, 403); }); - it('POST /api/docs/{did}/apikey/{linkId} is operational', async function() { + it('POST /api/docs/{did}/apikey is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; - const body = {"linkId": "Peace-And-Tranquility-2-Earth", "options":JSON.stringify(options)}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); assert.equal(resp.status, 200); - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, chimpy); - assert.equal(fetchResp.status, 200); - console.log(";;;;;;;;;;;", resp); - console.log("!!!!!!!!!!!", fetchResp.data); - //assert.deepEqual(fetchResp.data[0], { - // key: resp.key, - //}); + // Testing that response matches a string in Flickr Base58 style + assert.match(resp.data, /^[1-9a-km-zA-HJ-MP-Z]{22}$/); }); it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); - assert.equal(resp.status, 666); + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, chimpy); + assert.equal(fetchResp.status, 200); + assert.deepEqual( + omit(fetchResp.data, 'id'), + { + key: resp.data, + linkId: linkId, + docId: did, + options: { + ... options, + apikey: true, + } + } + ); }); - it('PUT /api/docs/{did}/apikey/{linkId} is operational', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); - assert.equal(resp.status, 666); + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 200); + const newLinkId = "Ground-Control-To-Major-Tom"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, {linkId: newLinkId}, chimpy); + assert.equal(patchResp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${newLinkId}`, chimpy); + assert.equal(fetchResp.status, 200); }); it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); - assert.equal(resp.status, 666); + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + assert.equal(fetchResp.status, 200); + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + assert.equal(deleteResp.status, 200); + const fetchResp2 = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + assert.equal(fetchResp2.status, 404); }); - it('GET /api/docs/{did}/apikeys is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); - assert.equal(resp.status, 666); - }); + // it('GET /api/docs/{did}/apikeys is operational', async function() { + // const did = await dbManager.testGetId('Curiosity'); + // const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); + // assert.equal(resp.status, 666); + // }); - it('DELETE /api/docs/{did}/apikeys is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const resp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy); - assert.equal(resp.status, 200); - }); + // it('DELETE /api/docs/{did}/apikeys is operational', async function() { + // const did = await dbManager.testGetId('Curiosity'); + // const resp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy); + // assert.equal(resp.status, 200); + // }); it('GET /api/zig is a 404', async function() { const resp = await axios.get(`${homeUrl}/api/zig`, chimpy); From 0f425f498ac2fe48f3d0ab86c04e94638114c7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Fri, 5 Jul 2024 10:16:48 +0200 Subject: [PATCH 13/31] WIP TODO test ADDED --- test/gen-server/ApiServer.ts | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index 180c2f72e5..c416c41b84 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1480,6 +1480,16 @@ describe('ApiServer', function() { assert.match(resp.data, /^[1-9a-km-zA-HJ-MP-Z]{22}$/); }); + // TODO test what happens in when having wrong did + + // TODO test what happens when access not set or set to bad value + + // TODO test what happens, when an arbitrary option is set + + // TODO test what heppens when posting an already existing linkId (And choose behaviour) + + // TODO test post on a did i dont own + it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; @@ -1503,6 +1513,12 @@ describe('ApiServer', function() { ); }); + // TODO test getting not existing linkId + + // TODO test getting linkid on non existing did + + // TODO test get on a doc i dont own + it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; @@ -1517,6 +1533,20 @@ describe('ApiServer', function() { assert.equal(fetchResp.status, 200); }); + // TODO test patch access authorized + + // TODO test patch options with other keys than wanted + + // TODO patch options arbitrary string reject + + // TODO test patch did reject + + // TODO test patch key resject + + // TODO test patch options.apikey reject + + // TODO test patch on did i dont own + it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; @@ -1532,18 +1562,36 @@ describe('ApiServer', function() { assert.equal(fetchResp2.status, 404); }); + // TODO delete not existing linkId + + // TODO delete on non existing did + + // TODO delete on did i dont own + // it('GET /api/docs/{did}/apikeys is operational', async function() { // const did = await dbManager.testGetId('Curiosity'); // const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); // assert.equal(resp.status, 666); // }); + // TODO Test on a did i dont own + + // TODO Test on a did that dont exists + + // TODO test on a did with no apikeys + // it('DELETE /api/docs/{did}/apikeys is operational', async function() { // const did = await dbManager.testGetId('Curiosity'); // const resp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy); // assert.equal(resp.status, 200); // }); + // TODO Test on a did i dont own + + // TODO Test on a did that dont exists + + // TODO test on a did with no apikeys + it('GET /api/zig is a 404', async function() { const resp = await axios.get(`${homeUrl}/api/zig`, chimpy); assert.equal(resp.status, 404); From 8c0c822644824078f98723ac98838ca5e2cf2bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 8 Jul 2024 12:40:25 +0200 Subject: [PATCH 14/31] tests POST WIP --- app/gen-server/ApiServer.ts | 28 +++++- app/gen-server/lib/homedb/HomeDBManager.ts | 2 +- test/gen-server/ApiServer.ts | 112 ++++++++++++++++++--- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index f1ce3da592..794a36ecaf 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -327,11 +327,37 @@ export class ApiServer { // POST /api/docs/:did/apiKey this._app.post('/api/docs/:did/apiKey', expressWrap(async (req, res) => { const did = req.params.did; + const doc = await this._dbManager.getDoc(req); + if (!doc){ + throw new ApiError(`No such doc: ${did}`, 404); + } + const linkId = req.body.linkId; + const query = await this._dbManager.getDocApiKeyByLinkId(did, linkId); + if (query){ + throw new ApiError("LinkId must be unique", 400); + } + if (!req.body.options){ + throw new ApiError("Missing body params: options", 400); + } + const options = JSON.parse(req.body.options); + if (!options.access){ + throw new ApiError("Missing options: access", 400); + } + const legalOptions = ["access"]; + Object.keys(options).forEach(element => { + if (!legalOptions.includes(element)){ + throw new ApiError("Invalid option: ${element}", 400); + } + }); + const legalAccessValues = ["Editor", "Viewer"]; + if (!legalAccessValues.includes(options.access)){ + throw new ApiError(`Invalid access value: ${options.access}`, 400); + } const key = await this._dbManager.createDocApiKey(did, req.body); return res.status(200).send(key); })); - // GET /api/docs/:did/apiKey/:LinkId + // GET /api/docs/:did/apiKey/:linkId this._app.get('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { const did = req.params.did; const linkId = req.params.linkId; diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index 9f2cb40ec3..ee031b9fc3 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2551,7 +2551,7 @@ export class HomeDBManager extends EventEmitter { public async getDocApiKeys(docId: string): Promise { return await this._connection.createQueryBuilder() - .select('key') + .select('shares') .from(Share, 'shares') .where('doc_id = :docId', {docId}) .getMany() || undefined; diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index c416c41b84..cf07f93a30 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1480,15 +1480,63 @@ describe('ApiServer', function() { assert.match(resp.data, /^[1-9a-km-zA-HJ-MP-Z]{22}$/); }); - // TODO test what happens in when having wrong did + it('POST /api/docs/{did}/apikey returns 404 on non existing :did', async function() { + const did = 'falsedocid_12'; + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 404); + }); + + // TODO test what happens when access not set + it('POST /api/docs/{did}/apikey returns 400 when no options', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 400); + }); + + it('POST /api/docs/{did}/apikey returns 400 when options.access is missing', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {}; + const linkId = "Peace-And-Tranquility-3-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 400); + }); - // TODO test what happens when access not set or set to bad value + it('POST /api/docs/{did}/apikey returns 400 when options.access have bad value', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Root"}; + const linkId = "Peace-And-Tranquility-4-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 400); + }); - // TODO test what happens, when an arbitrary option is set + it('POST /api/docs/{did}/apikey returns 400 on arbitrary options', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor", injection: "It's a bad, bad, thing"}; + const linkId = "Peace-And-Tranquility-5-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 400); + }); - // TODO test what heppens when posting an already existing linkId (And choose behaviour) + it('POST /api/docs/{did}/apikey status 400 when linkId already exists for given :did', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + assert.equal(resp.status, 400); + }); // TODO test post on a did i dont own + it('POST /api/docs/{did}/apikey returns 404 when not owning doc', async function() { + }); it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); @@ -1568,11 +1616,26 @@ describe('ApiServer', function() { // TODO delete on did i dont own - // it('GET /api/docs/{did}/apikeys is operational', async function() { - // const did = await dbManager.testGetId('Curiosity'); - // const resp = await axios.post(`${homeUrl}/api/docs/${did}`, chimpy); - // assert.equal(resp.status, 666); - // }); + it('GET /api/docs/{did}/apikeys is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options1 = {access:"Editor"}; + // Creation of the first doc-api-key + const linkId1 = "Peace-And-Tranquility-2-Earth"; + const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; + const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + assert.equal(respPost1.status, 200); + // Creation of the second doc-api-key + const options2 = {access:"Viewer"}; + const linkId2 = "Ground-Control-To-Major-Tom"; + const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; + const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + assert.equal(respPost2.status, 200); + // Get doc-api-keys + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + assert.equal(resp.status, 200); + // check that there's two doc-api-keys + assert.equal(resp.data.length, 2); + }); // TODO Test on a did i dont own @@ -1580,11 +1643,32 @@ describe('ApiServer', function() { // TODO test on a did with no apikeys - // it('DELETE /api/docs/{did}/apikeys is operational', async function() { - // const did = await dbManager.testGetId('Curiosity'); - // const resp = await axios.delete(`${homeUrl}/api/docs/${did}`, chimpy); - // assert.equal(resp.status, 200); - // }); + it('DELETE /api/docs/{did}/apikeys is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options1 = {access:"Editor"}; + // Creation of the first doc-api-key + const linkId1 = "Peace-And-Tranquility-3-Earth"; + const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; + const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + assert.equal(respPost1.status, 200); + // Creation of the second doc-api-key + const options2 = {access:"Viewer"}; + const linkId2 = "Ground-Control-To-Major-Tom"; + const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; + const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + assert.equal(respPost2.status, 200); + // Get doc-api-keys + const respFetch = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + assert.equal(respFetch.status, 200); + // check that there's two doc-api-keys + assert.equal(respFetch.data.length, 2); + const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + assert.equal(resp.status, 200); + // check that there's no longer doc-api-keys + const respFetch2 = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + assert.equal(respFetch2.status, 200); + assert.equal(respFetch2.data.length, 0); + }); // TODO Test on a did i dont own From c4eaa4a25b0d4289cfdd7e786555f98b1cc73234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 8 Jul 2024 17:52:22 +0200 Subject: [PATCH 15/31] POST done WIP tests --- test/gen-server/ApiServer.ts | 147 ++++++++++++++++++++++++++++++----- 1 file changed, 129 insertions(+), 18 deletions(-) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index cf07f93a30..722220dceb 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1474,10 +1474,12 @@ describe('ApiServer', function() { const options = {access:"Editor"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api is created assert.equal(resp.status, 200); - // Testing that response matches a string in Flickr Base58 style - assert.match(resp.data, /^[1-9a-km-zA-HJ-MP-Z]{22}$/); + // Assert that response matches a string in Flickr Base58 style + assert.match(resp.data, /^[1-9a-km-zA-HJ-NP-Z]{22}$/); }); it('POST /api/docs/{did}/apikey returns 404 on non existing :did', async function() { @@ -1485,16 +1487,19 @@ describe('ApiServer', function() { const options = {access:"Editor"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc doesn't exist assert.equal(resp.status, 404); }); - // TODO test what happens when access not set it('POST /api/docs/{did}/apikey returns 400 when no options', async function() { const did = await dbManager.testGetId('Curiosity'); const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key creation fails when no `options` set assert.equal(resp.status, 400); }); @@ -1503,7 +1508,9 @@ describe('ApiServer', function() { const options = {}; const linkId = "Peace-And-Tranquility-3-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key creation fails when no `options.access` set assert.equal(resp.status, 400); }); @@ -1512,7 +1519,9 @@ describe('ApiServer', function() { const options = {access:"Root"}; const linkId = "Peace-And-Tranquility-4-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that `options.access` value is illegal assert.equal(resp.status, 400); }); @@ -1521,7 +1530,9 @@ describe('ApiServer', function() { const options = {access: "Editor", injection: "It's a bad, bad, thing"}; const linkId = "Peace-And-Tranquility-5-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that `options` keys are illegal assert.equal(resp.status, 400); }); @@ -1530,7 +1541,9 @@ describe('ApiServer', function() { const options = {access:"Editor"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that linkId is not unique assert.equal(resp.status, 400); }); @@ -1541,12 +1554,17 @@ describe('ApiServer', function() { it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; - const linkId = "Peace-And-Tranquility-2-Earth"; + const linkId = "Small-step-for-man"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created assert.equal(resp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, chimpy); + // Assert that doc api key can be read assert.equal(fetchResp.status, 200); + // Assert that doc api key is well formed assert.deepEqual( omit(fetchResp.data, 'id'), { @@ -1561,120 +1579,213 @@ describe('ApiServer', function() { ); }); - // TODO test getting not existing linkId + it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing linkId', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "non-existing"; + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that :linkId doesn't exist + assert.equal(fetchResp.status, 404); + }); - // TODO test getting linkid on non existing did + // TODO test getting linkId on non existing did + it('GET /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO test get on a doc i dont own + it('GET /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; - const linkId = "Peace-And-Tranquility-2-Earth"; + const linkId = "Great-step-for-humanity"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created assert.equal(resp.status, 200); + const newLinkId = "Ground-Control-To-Major-Tom"; const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, {linkId: newLinkId}, chimpy); + // Assert that doc api key is modified assert.equal(patchResp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${newLinkId}`, chimpy); + // Assert that doc api key can be read by its new linkId assert.equal(fetchResp.status, 200); }); // TODO test patch access authorized + it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO test patch options with other keys than wanted + it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO patch options arbitrary string reject + it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO test patch did reject + it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO test patch key resject + it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO test patch options.apikey reject + it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO test patch on did i dont own + it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access:"Editor"}; - const linkId = "Peace-And-Tranquility-2-Earth"; + const linkId = "Houston-we-have-a-problem"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created assert.equal(resp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that doc api key can be read assert.equal(fetchResp.status, 200); + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that endpoint responds with SUCCESS assert.equal(deleteResp.status, 200); + const fetchResp2 = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that doc api key no longer exists assert.equal(fetchResp2.status, 404); }); // TODO delete not existing linkId + it('DELETE /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO delete on non existing did + it('DELETE /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); // TODO delete on did i dont own + it('DELETE /api/docs/{did}/apikey/{linkId} returns', async function() { + + }); it('GET /api/docs/{did}/apikeys is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options1 = {access:"Editor"}; + const did = await dbManager.testGetId('Boredom'); + // Creation of the first doc-api-key - const linkId1 = "Peace-And-Tranquility-2-Earth"; + const options1 = {access:"Editor"}; + const linkId1 = "Major-Tom-To-Ground-Control"; const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + // Assert that first doc api key is created assert.equal(respPost1.status, 200); + // Creation of the second doc-api-key const options2 = {access:"Viewer"}; const linkId2 = "Ground-Control-To-Major-Tom"; const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + // Assert that second doc api key is created assert.equal(respPost2.status, 200); + // Get doc-api-keys const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that endpoint reponds with SUCCESS assert.equal(resp.status, 200); - // check that there's two doc-api-keys + + // Assert there's two doc api keys for this 'Boredom' assert.equal(resp.data.length, 2); }); // TODO Test on a did i dont own + it('GET /api/docs/{did}/apikeys returns', async function() { + + }); // TODO Test on a did that dont exists + it('GET /api/docs/{did}/apikeys returns', async function() { + + }); // TODO test on a did with no apikeys + it('GET /api/docs/{did}/apikeys returns', async function() { + + }); it('DELETE /api/docs/{did}/apikeys is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options1 = {access:"Editor"}; + const did = await dbManager.testGetId('Apathy'); + // Creation of the first doc-api-key - const linkId1 = "Peace-And-Tranquility-3-Earth"; + const options1 = {access:"Editor"}; + const linkId1 = "Peace-And-Tranquility-2-Earth"; const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + // Assert that first doc api key is created assert.equal(respPost1.status, 200); + // Creation of the second doc-api-key const options2 = {access:"Viewer"}; - const linkId2 = "Ground-Control-To-Major-Tom"; + const linkId2 = "Ground-Control-4-Major-Tom"; const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + // Assert that second doc api key is created assert.equal(respPost2.status, 200); + // Get doc-api-keys const respFetch = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that endpoint responds with SUCCESS assert.equal(respFetch.status, 200); - // check that there's two doc-api-keys + // Assert there's two doc api keys for this 'Apathy' assert.equal(respFetch.data.length, 2); + + // Delete all doc api keys for 'Apathy' const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that endpoint responds with SUCCESS assert.equal(resp.status, 200); - // check that there's no longer doc-api-keys + + // check that there's no longer doc api keys const respFetch2 = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that there is no longer doc api keys for 'Apathy' assert.equal(respFetch2.status, 200); assert.equal(respFetch2.data.length, 0); }); // TODO Test on a did i dont own + it('DELETE /api/docs/{did}/apikeys returns', async function() { + + }); // TODO Test on a did that dont exists + it('DELETE /api/docs/{did}/apikeys returns', async function() { + + }); // TODO test on a did with no apikeys + it('DELETE /api/docs/{did}/apikeys returns', async function() { + + }); it('GET /api/zig is a 404', async function() { const resp = await axios.get(`${homeUrl}/api/zig`, chimpy); From 8849d0e5812d19eba2accf79318e015b1bcfa05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Tue, 9 Jul 2024 17:38:03 +0200 Subject: [PATCH 16/31] Endpoint Tests Donish TODO fix GET /api/docs/{did}/apikey/{linkId}i\n returns 200 instead of 403 when not owning document --- app/gen-server/ApiServer.ts | 100 +++++++-- app/gen-server/lib/homedb/HomeDBManager.ts | 3 +- test/gen-server/ApiServer.ts | 242 ++++++++++++++++----- 3 files changed, 275 insertions(+), 70 deletions(-) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 794a36ecaf..13847bac9e 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -21,6 +21,7 @@ import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerPara isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils'; import {IWidgetRepository} from 'app/server/lib/WidgetRepository'; import {getCookieDomain} from 'app/server/lib/gristSessions'; +import { ShareOptions } from 'app/common/ShareOptions'; // exposed for testing purposes export const Deps = { @@ -331,30 +332,18 @@ export class ApiServer { if (!doc){ throw new ApiError(`No such doc: ${did}`, 404); } + const linkId = req.body.linkId; const query = await this._dbManager.getDocApiKeyByLinkId(did, linkId); if (query){ throw new ApiError("LinkId must be unique", 400); } - if (!req.body.options){ - throw new ApiError("Missing body params: options", 400); - } - const options = JSON.parse(req.body.options); - if (!options.access){ - throw new ApiError("Missing options: access", 400); - } - const legalOptions = ["access"]; - Object.keys(options).forEach(element => { - if (!legalOptions.includes(element)){ - throw new ApiError("Invalid option: ${element}", 400); - } - }); - const legalAccessValues = ["Editor", "Viewer"]; - if (!legalAccessValues.includes(options.access)){ - throw new ApiError(`Invalid access value: ${options.access}`, 400); - } + const options = sanitizeDocApiKeyOptions(req.body.options); + req.body.options = JSON.stringify(options); + const key = await this._dbManager.createDocApiKey(did, req.body); - return res.status(200).send(key); + + return sendOkReply(req, res, key); })); // GET /api/docs/:did/apiKey/:linkId @@ -362,13 +351,38 @@ export class ApiServer { const did = req.params.did; const linkId = req.params.linkId; const query = await this._dbManager.getDocApiKeyByLinkId(did, linkId); - return query ? res.status(200).send(query) : res.status(404).send(); + return query ? sendOkReply(req, res, query) : res.status(404).send(); })); // PATCH /api/docs/:did/apiKey/:linkId this._app.patch('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { const did = req.params.did; const linkId = req.params.linkId; + + const doc = await this._dbManager.getDoc(req); + if (!doc){ + throw new ApiError(`No such doc: ${did}`, 404); + } + + if (req.body.docId){ + throw new ApiError("Can't update DocId", 400); + } + + if (req.body.key){ + throw new ApiError("Can't update key", 400); + } + + const queryLinkId = await this._dbManager.getDocApiKeyByLinkId(did, req.body.linkId); + if (queryLinkId){ + throw new ApiError("LinkId must be unique", 400); + } + + // In order to catch {options: ""} case + if (Object.keys(req.body).includes("options")){ + const options = sanitizeDocApiKeyOptions(req.body.options); + req.body.options = JSON.stringify(options); + } + const query = await this._dbManager.updateDocApiKeyByLinkId(did, linkId, req.body); return sendOkReply(req, res, query); })); @@ -377,6 +391,17 @@ export class ApiServer { this._app.delete('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { const did = req.params.did; const linkId = req.params.linkId; + + const doc = await this._dbManager.getDoc(req); + if (!doc){ + throw new ApiError(`No such doc: ${did}`, 404); + } + + const linkId4Did = await this._dbManager.getDocApiKeyByLinkId(did, linkId); + if (!linkId4Did){ + throw new ApiError(`Invalid LinkId: ${linkId}`, 404); + } + const query = await this._dbManager.deleteDocApiKeyByLinkId(did, linkId); return sendOkReply(req, res, query); })); @@ -384,6 +409,12 @@ export class ApiServer { // GET /api/docs/:did/apiKeys this._app.get('/api/docs/:did/apiKeys', expressWrap(async (req, res) => { const did = req.params.did; + + const doc = await this._dbManager.getDoc(req); + if (!doc){ + throw new ApiError(`No such doc: ${did}`, 404); + } + const query = await this._dbManager.getDocApiKeys(did); return sendOkReply(req, res, query); })); @@ -391,6 +422,12 @@ export class ApiServer { // DELETE /api/docs/:did/apiKeys this._app.delete('/api/docs/:did/apiKeys', expressWrap(async (req, res) => { const did = req.params.did; + + const doc = await this._dbManager.getDoc(req); + if (!doc){ + throw new ApiError(`No such doc: ${did}`, 404); + } + const query = await this._dbManager.deleteDocApiKeys(did); return sendOkReply(req, res, query); })); @@ -770,3 +807,28 @@ async function updateApiKeyWithRetry(manager: EntityManager, user: User): Promis } throw new Error('Could not generate a valid api key.'); } + +function sanitizeDocApiKeyOptions(rawOptions: string): ShareOptions { + const legalOptions = ["access"]; + const legalAccessValues = ["Editor", "Viewer"]; + + if (!rawOptions){ + throw new ApiError("Missing body params: options", 400); + } + + const options = JSON.parse(rawOptions); + if (!options.access){ + throw new ApiError("Missing options: access", 400); + } + + Object.keys(options).forEach(element => { + if (!legalOptions.includes(element)){ + throw new ApiError("Invalid option: ${element}", 400); + } + }); + + if (!legalAccessValues.includes(options.access)){ + throw new ApiError(`Invalid access value: ${options.access}`, 400); + } + return {...options, "apikey": true}; +} diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index ee031b9fc3..b4b5f1b02f 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2509,7 +2509,6 @@ export class HomeDBManager extends EventEmitter { public async createDocApiKey(docId: string, share: ShareInfo) { const key = makeId(); - const apiKey_options = {...JSON.parse(share.options), "apikey": true}; const query = await this._connection.createQueryBuilder() .insert() .setParameter('options', share.options) @@ -2517,7 +2516,7 @@ export class HomeDBManager extends EventEmitter { .values({ linkId: share.linkId, docId: docId, - options: apiKey_options, + options: JSON.parse(share.options), key, }) .execute() || undefined; diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index 722220dceb..6d1debe3fb 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1547,8 +1547,15 @@ describe('ApiServer', function() { assert.equal(resp.status, 400); }); - // TODO test post on a did i dont own - it('POST /api/docs/{did}/apikey returns 404 when not owning doc', async function() { + it('POST /api/docs/{did}/apikey returns 403 when not owning doc', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "I-am-nobody"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, nobody); + // Assert that it needs to be owner of :did to create a doc api key + assert.equal(resp.status, 403); }); it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { @@ -1579,6 +1586,15 @@ describe('ApiServer', function() { ); }); + it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing did', async function() { + const did = 'falsedocid_12'; + const linkId = "Peace-And-Tranquility-2-Earth"; + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that :did doesn't exist + assert.equal(fetchResp.status, 404); + }); + it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing linkId', async function() { const did = await dbManager.testGetId('Curiosity'); const linkId = "non-existing"; @@ -1588,19 +1604,24 @@ describe('ApiServer', function() { assert.equal(fetchResp.status, 404); }); - // TODO test getting linkId on non existing did - it('GET /api/docs/{did}/apikey/{linkId} returns', async function() { - - }); + it('GET /api/docs/{did}/apikey/{linkId} returns 403 when not owning :did', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "Created-By-Owners-Only"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; - // TODO test get on a doc i dont own - it('GET /api/docs/{did}/apikey/{linkId} returns', async function() { + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, nobody); + // Assert that doc api key can't be read if not owner + assert.equal(fetchResp.status, 403); }); it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; + const options = {access: "Editor"}; const linkId = "Great-step-for-humanity"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -1618,44 +1639,113 @@ describe('ApiServer', function() { assert.equal(fetchResp.status, 200); }); - // TODO test patch access authorized - it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} returns 200 on options.access modification', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Viewer"}; + const linkId = "Ground-Control-To-Major-Tom"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(options)}, chimpy); + // Assert that options.access is modified + assert.equal(patchResp.status, 200, "options.access is modified"); + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that modified doc api key still can be read + assert.equal(fetchResp.status, 200, "doc api key can still be read"); + assert.equal(JSON.parse(fetchResp.data.options).access, options.access); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 when empty option', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = ""; + const linkId = "Ground-Control-To-Major-Tom"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options}, chimpy); + // Assert that empty option is rejected + assert.equal(patchResp.status, 400); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on bad options key', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Viewer", bad:"injection"}; + const linkId = "Ground-Control-To-Major-Tom"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(options)}, chimpy); + // Assert that bad options keys are rejected + assert.equal(patchResp.status, 400); }); - // TODO test patch options with other keys than wanted - it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on illegal value of options.access', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "injection"}; + const linkId = "Ground-Control-To-Major-Tom"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(options)}, chimpy); + // Assert that options.access illegal values are rejected + assert.equal(patchResp.status, 400); }); - // TODO patch options arbitrary string reject - it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on did update', async function() { + const did = await dbManager.testGetId('Curiosity'); + const newdid = "Another-Document-Id"; + const linkId = "Ground-Control-To-Major-Tom"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {docId: newdid}, chimpy); + // Assert that options.access is modified + assert.equal(patchResp.status, 400); }); - // TODO test patch did reject - it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on key update', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "Ground-Control-To-Major-Tom"; + const key = "ViciousKeyInjection"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {key}, chimpy); + // Assert that options.access is modified + assert.equal(patchResp.status, 400); }); - // TODO test patch key resject - it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on options.apikey update', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {apikey: "false"}; + const linkId = "Ground-Control-To-Major-Tom"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(options)}, chimpy); + // Assert that options.apikey update is rejected + assert.equal(patchResp.status, 400); }); - // TODO test patch options.apikey reject - it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} returns 404 on did not owned', async function() { + const did = "not-a-document"; + const linkId = "Ground-Control-To-Major-Tom"; + const newLinkId = "Ground-Control-to-Major-Tim"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {linkId: newLinkId}, chimpy); + // Assert that update of a doc api key of not :did is rejected + assert.equal(patchResp.status, 404); }); - // TODO test patch on did i dont own - it('PATCH /api/docs/{did}/apikey/{linkId} returns', async function() { + it('PATCH /api/docs/{did}/apikey/{linkId} returns 403 on did not owned', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "Ground-Control-To-Major-Tom"; + const newLinkId = "Ground-Control-to-Major-Tim"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {linkId: newLinkId}, nobody); + // Assert that update of a doc api key of not :did is rejected + assert.equal(patchResp.status, 403); }); it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; + const options = {access: "Editor"}; const linkId = "Houston-we-have-a-problem"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -1676,36 +1766,61 @@ describe('ApiServer', function() { assert.equal(fetchResp2.status, 404); }); - // TODO delete not existing linkId - it('DELETE /api/docs/{did}/apikey/{linkId} returns', async function() { + it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :did', async function() { + const did = "not-a-document"; + const linkId = "Lucy-In-The-Sky"; + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that endpoint responds with SUCCESS + assert.equal(deleteResp.status, 404); }); - // TODO delete on non existing did - it('DELETE /api/docs/{did}/apikey/{linkId} returns', async function() { + it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :linkId', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor"}; + const linkId = "Lucy-In-The-Sky"; + const oupsLinkId = "Mary-In-The-Sky" + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${oupsLinkId}`, chimpy); + // Assert that deletion FAILS on invalid LinkId + assert.equal(deleteResp.status, 404); }); - // TODO delete on did i dont own - it('DELETE /api/docs/{did}/apikey/{linkId} returns', async function() { + it('DELETE /api/docs/{did}/apikey/{linkId} returns 403 when not owning doc', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor"}; + const linkId = "With-Diamonds"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, nobody); + // Assert that deletion FAILS when not owning doc + assert.equal(deleteResp.status, 403); }); it('GET /api/docs/{did}/apikeys is operational', async function() { const did = await dbManager.testGetId('Boredom'); // Creation of the first doc-api-key - const options1 = {access:"Editor"}; + const options1 = {access: "Editor"}; const linkId1 = "Major-Tom-To-Ground-Control"; - const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; + const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); // Assert that first doc api key is created assert.equal(respPost1.status, 200); // Creation of the second doc-api-key - const options2 = {access:"Viewer"}; + const options2 = {access: "Viewer"}; const linkId2 = "Ground-Control-To-Major-Tom"; - const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; + const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); // Assert that second doc api key is created assert.equal(respPost2.status, 200); @@ -1719,19 +1834,47 @@ describe('ApiServer', function() { assert.equal(resp.data.length, 2); }); - // TODO Test on a did i dont own - it('GET /api/docs/{did}/apikeys returns', async function() { + it('GET /api/docs/{did}/apikeys returns 403 when not owning :did ', async function() { + const did = await dbManager.testGetId('Boredom'); + + // Creation of the first doc-api-key + const options1 = {access: "Editor"}; + const linkId1 = "Major-TimE-To-Ground-Control"; + const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; + const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + // Assert that first doc api key is created + assert.equal(respPost1.status, 200); + + // Creation of the second doc-api-key + const options2 = {access: "Viewer"}; + const linkId2 = "Ground-Control-To-Major-Tim"; + const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; + const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + // Assert that second doc api key is created + assert.equal(respPost2.status, 200); + // Get doc-api-keys + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, nobody); + // Assert that READ fails when not owning :did + assert.equal(resp.status, 403); }); - // TODO Test on a did that dont exists - it('GET /api/docs/{did}/apikeys returns', async function() { + it('GET /api/docs/{did}/apikeys returns 404 on not existing :did', async function() { + const did = "not-a-document"; + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that READ fails when :did not exists + assert.equal(resp.status, 404); }); - // TODO test on a did with no apikeys - it('GET /api/docs/{did}/apikeys returns', async function() { + it('GET /api/docs/{did}/apikeys returns 200 and empty array when no keys', async function() { + const did = await dbManager.testGetId('Apathy'); + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that READ is succeffull + assert.equal(resp.status, 200); + // Assertt that data is an empty list + assert.equal(resp.data.length, 0) }); it('DELETE /api/docs/{did}/apikeys is operational', async function() { @@ -1772,19 +1915,20 @@ describe('ApiServer', function() { assert.equal(respFetch2.data.length, 0); }); - // TODO Test on a did i dont own - it('DELETE /api/docs/{did}/apikeys returns', async function() { - - }); - - // TODO Test on a did that dont exists - it('DELETE /api/docs/{did}/apikeys returns', async function() { + it('DELETE /api/docs/{did}/apikeys returns 403 when not owning :did', async function() { + const did = await dbManager.testGetId('Curiosity'); + const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, nobody); + // Assert that deletion FAILS when not owning document + assert.equal(resp.status, 403); }); - // TODO test on a did with no apikeys - it('DELETE /api/docs/{did}/apikeys returns', async function() { + it('DELETE /api/docs/{did}/apikeys returns 404 when deleting on non existing :did', async function() { + const did = "not-a-document"; + const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that READ fails when :did not exists + assert.equal(resp.status, 404); }); it('GET /api/zig is a 404', async function() { From 50e58dbf82b230df4de3018984ce50eca10db9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Tue, 9 Jul 2024 17:38:38 +0200 Subject: [PATCH 17/31] Endpoint Tests Donish TODO fix GET /api/docs/{did}/apikey/{linkId}i\n returns 200 instead of 403 when not owning document --- test/gen-server/ApiServer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index 6d1debe3fb..e4c5a971ff 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1779,7 +1779,7 @@ describe('ApiServer', function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "Editor"}; const linkId = "Lucy-In-The-Sky"; - const oupsLinkId = "Mary-In-The-Sky" + const oupsLinkId = "Mary-In-The-Sky"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); @@ -1874,7 +1874,7 @@ describe('ApiServer', function() { // Assert that READ is succeffull assert.equal(resp.status, 200); // Assertt that data is an empty list - assert.equal(resp.data.length, 0) + assert.equal(resp.data.length, 0); }); it('DELETE /api/docs/{did}/apikeys is operational', async function() { From 64abd51c3e271d008a5aedcd80049c552e808174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Wed, 10 Jul 2024 14:31:29 +0200 Subject: [PATCH 18/31] Chore: Endpoints Doc Api Key DONE --- app/gen-server/ApiServer.ts | 5 + test/gen-server/ApiServer.ts | 971 ++++++++++++++++++----------------- 2 files changed, 514 insertions(+), 462 deletions(-) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 13847bac9e..2925325179 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -349,6 +349,11 @@ export class ApiServer { // GET /api/docs/:did/apiKey/:linkId this._app.get('/api/docs/:did/apiKey/:linkId', expressWrap(async (req, res) => { const did = req.params.did; + + const doc = await this._dbManager.getDoc(req); + if (!doc){ + throw new ApiError(`No such doc: ${did}`, 404); + } const linkId = req.params.linkId; const query = await this._dbManager.getDocApiKeyByLinkId(did, linkId); return query ? sendOkReply(req, res, query) : res.status(404).send(); diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index e4c5a971ff..d3eb9679e4 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -1469,468 +1469,6 @@ describe('ApiServer', function() { assert.equal(resp.status, 403); }); - it('POST /api/docs/{did}/apikey is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; - const linkId = "Peace-And-Tranquility-2-Earth"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api is created - assert.equal(resp.status, 200); - // Assert that response matches a string in Flickr Base58 style - assert.match(resp.data, /^[1-9a-km-zA-HJ-NP-Z]{22}$/); - }); - - it('POST /api/docs/{did}/apikey returns 404 on non existing :did', async function() { - const did = 'falsedocid_12'; - const options = {access:"Editor"}; - const linkId = "Peace-And-Tranquility-2-Earth"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc doesn't exist - assert.equal(resp.status, 404); - }); - - it('POST /api/docs/{did}/apikey returns 400 when no options', async function() { - const did = await dbManager.testGetId('Curiosity'); - const linkId = "Peace-And-Tranquility-2-Earth"; - const body = {"linkId": linkId}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key creation fails when no `options` set - assert.equal(resp.status, 400); - }); - - it('POST /api/docs/{did}/apikey returns 400 when options.access is missing', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {}; - const linkId = "Peace-And-Tranquility-3-Earth"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key creation fails when no `options.access` set - assert.equal(resp.status, 400); - }); - - it('POST /api/docs/{did}/apikey returns 400 when options.access have bad value', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Root"}; - const linkId = "Peace-And-Tranquility-4-Earth"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that `options.access` value is illegal - assert.equal(resp.status, 400); - }); - - it('POST /api/docs/{did}/apikey returns 400 on arbitrary options', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor", injection: "It's a bad, bad, thing"}; - const linkId = "Peace-And-Tranquility-5-Earth"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that `options` keys are illegal - assert.equal(resp.status, 400); - }); - - it('POST /api/docs/{did}/apikey status 400 when linkId already exists for given :did', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; - const linkId = "Peace-And-Tranquility-2-Earth"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that linkId is not unique - assert.equal(resp.status, 400); - }); - - it('POST /api/docs/{did}/apikey returns 403 when not owning doc', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; - const linkId = "I-am-nobody"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, nobody); - // Assert that it needs to be owner of :did to create a doc api key - assert.equal(resp.status, 403); - }); - - it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; - const linkId = "Small-step-for-man"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key is created - assert.equal(resp.status, 200); - - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, chimpy); - // Assert that doc api key can be read - assert.equal(fetchResp.status, 200); - // Assert that doc api key is well formed - assert.deepEqual( - omit(fetchResp.data, 'id'), - { - key: resp.data, - linkId: linkId, - docId: did, - options: { - ... options, - apikey: true, - } - } - ); - }); - - it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing did', async function() { - const did = 'falsedocid_12'; - const linkId = "Peace-And-Tranquility-2-Earth"; - - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); - // Assert that :did doesn't exist - assert.equal(fetchResp.status, 404); - }); - - it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing linkId', async function() { - const did = await dbManager.testGetId('Curiosity'); - const linkId = "non-existing"; - - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); - // Assert that :linkId doesn't exist - assert.equal(fetchResp.status, 404); - }); - - it('GET /api/docs/{did}/apikey/{linkId} returns 403 when not owning :did', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; - const linkId = "Created-By-Owners-Only"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key is created - assert.equal(resp.status, 200); - - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, nobody); - // Assert that doc api key can't be read if not owner - assert.equal(fetchResp.status, 403); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; - const linkId = "Great-step-for-humanity"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key is created - assert.equal(resp.status, 200); - - const newLinkId = "Ground-Control-To-Major-Tom"; - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, {linkId: newLinkId}, chimpy); - // Assert that doc api key is modified - assert.equal(patchResp.status, 200); - - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${newLinkId}`, chimpy); - // Assert that doc api key can be read by its new linkId - assert.equal(fetchResp.status, 200); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 200 on options.access modification', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Viewer"}; - const linkId = "Ground-Control-To-Major-Tom"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {options: JSON.stringify(options)}, chimpy); - // Assert that options.access is modified - assert.equal(patchResp.status, 200, "options.access is modified"); - - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); - // Assert that modified doc api key still can be read - assert.equal(fetchResp.status, 200, "doc api key can still be read"); - assert.equal(JSON.parse(fetchResp.data.options).access, options.access); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 when empty option', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = ""; - const linkId = "Ground-Control-To-Major-Tom"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {options}, chimpy); - // Assert that empty option is rejected - assert.equal(patchResp.status, 400); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on bad options key', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Viewer", bad:"injection"}; - const linkId = "Ground-Control-To-Major-Tom"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {options: JSON.stringify(options)}, chimpy); - // Assert that bad options keys are rejected - assert.equal(patchResp.status, 400); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on illegal value of options.access', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "injection"}; - const linkId = "Ground-Control-To-Major-Tom"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {options: JSON.stringify(options)}, chimpy); - // Assert that options.access illegal values are rejected - assert.equal(patchResp.status, 400); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on did update', async function() { - const did = await dbManager.testGetId('Curiosity'); - const newdid = "Another-Document-Id"; - const linkId = "Ground-Control-To-Major-Tom"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {docId: newdid}, chimpy); - // Assert that options.access is modified - assert.equal(patchResp.status, 400); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on key update', async function() { - const did = await dbManager.testGetId('Curiosity'); - const linkId = "Ground-Control-To-Major-Tom"; - const key = "ViciousKeyInjection"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {key}, chimpy); - // Assert that options.access is modified - assert.equal(patchResp.status, 400); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on options.apikey update', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {apikey: "false"}; - const linkId = "Ground-Control-To-Major-Tom"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {options: JSON.stringify(options)}, chimpy); - // Assert that options.apikey update is rejected - assert.equal(patchResp.status, 400); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 404 on did not owned', async function() { - const did = "not-a-document"; - const linkId = "Ground-Control-To-Major-Tom"; - const newLinkId = "Ground-Control-to-Major-Tim"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {linkId: newLinkId}, chimpy); - // Assert that update of a doc api key of not :did is rejected - assert.equal(patchResp.status, 404); - }); - - it('PATCH /api/docs/{did}/apikey/{linkId} returns 403 on did not owned', async function() { - const did = await dbManager.testGetId('Curiosity'); - const linkId = "Ground-Control-To-Major-Tom"; - const newLinkId = "Ground-Control-to-Major-Tim"; - - const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, - {linkId: newLinkId}, nobody); - // Assert that update of a doc api key of not :did is rejected - assert.equal(patchResp.status, 403); - }); - - it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; - const linkId = "Houston-we-have-a-problem"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key is created - assert.equal(resp.status, 200); - - const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); - // Assert that doc api key can be read - assert.equal(fetchResp.status, 200); - - const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); - // Assert that endpoint responds with SUCCESS - assert.equal(deleteResp.status, 200); - - const fetchResp2 = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); - // Assert that doc api key no longer exists - assert.equal(fetchResp2.status, 404); - }); - - it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :did', async function() { - const did = "not-a-document"; - const linkId = "Lucy-In-The-Sky"; - - const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); - // Assert that endpoint responds with SUCCESS - assert.equal(deleteResp.status, 404); - }); - - it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :linkId', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; - const linkId = "Lucy-In-The-Sky"; - const oupsLinkId = "Mary-In-The-Sky"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key is created - assert.equal(resp.status, 200); - - const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${oupsLinkId}`, chimpy); - // Assert that deletion FAILS on invalid LinkId - assert.equal(deleteResp.status, 404); - }); - - it('DELETE /api/docs/{did}/apikey/{linkId} returns 403 when not owning doc', async function() { - const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; - const linkId = "With-Diamonds"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; - - const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); - // Assert that doc api key is created - assert.equal(resp.status, 200); - - const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, nobody); - // Assert that deletion FAILS when not owning doc - assert.equal(deleteResp.status, 403); - }); - - it('GET /api/docs/{did}/apikeys is operational', async function() { - const did = await dbManager.testGetId('Boredom'); - - // Creation of the first doc-api-key - const options1 = {access: "Editor"}; - const linkId1 = "Major-Tom-To-Ground-Control"; - const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; - const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); - // Assert that first doc api key is created - assert.equal(respPost1.status, 200); - - // Creation of the second doc-api-key - const options2 = {access: "Viewer"}; - const linkId2 = "Ground-Control-To-Major-Tom"; - const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; - const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); - // Assert that second doc api key is created - assert.equal(respPost2.status, 200); - - // Get doc-api-keys - const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); - // Assert that endpoint reponds with SUCCESS - assert.equal(resp.status, 200); - - // Assert there's two doc api keys for this 'Boredom' - assert.equal(resp.data.length, 2); - }); - - it('GET /api/docs/{did}/apikeys returns 403 when not owning :did ', async function() { - const did = await dbManager.testGetId('Boredom'); - - // Creation of the first doc-api-key - const options1 = {access: "Editor"}; - const linkId1 = "Major-TimE-To-Ground-Control"; - const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; - const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); - // Assert that first doc api key is created - assert.equal(respPost1.status, 200); - - // Creation of the second doc-api-key - const options2 = {access: "Viewer"}; - const linkId2 = "Ground-Control-To-Major-Tim"; - const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; - const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); - // Assert that second doc api key is created - assert.equal(respPost2.status, 200); - - // Get doc-api-keys - const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, nobody); - // Assert that READ fails when not owning :did - assert.equal(resp.status, 403); - }); - - it('GET /api/docs/{did}/apikeys returns 404 on not existing :did', async function() { - const did = "not-a-document"; - - const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); - // Assert that READ fails when :did not exists - assert.equal(resp.status, 404); - }); - - it('GET /api/docs/{did}/apikeys returns 200 and empty array when no keys', async function() { - const did = await dbManager.testGetId('Apathy'); - - const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); - // Assert that READ is succeffull - assert.equal(resp.status, 200); - // Assertt that data is an empty list - assert.equal(resp.data.length, 0); - }); - - it('DELETE /api/docs/{did}/apikeys is operational', async function() { - const did = await dbManager.testGetId('Apathy'); - - // Creation of the first doc-api-key - const options1 = {access:"Editor"}; - const linkId1 = "Peace-And-Tranquility-2-Earth"; - const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; - const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); - // Assert that first doc api key is created - assert.equal(respPost1.status, 200); - - // Creation of the second doc-api-key - const options2 = {access:"Viewer"}; - const linkId2 = "Ground-Control-4-Major-Tom"; - const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; - const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); - // Assert that second doc api key is created - assert.equal(respPost2.status, 200); - - // Get doc-api-keys - const respFetch = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); - // Assert that endpoint responds with SUCCESS - assert.equal(respFetch.status, 200); - // Assert there's two doc api keys for this 'Apathy' - assert.equal(respFetch.data.length, 2); - - // Delete all doc api keys for 'Apathy' - const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); - // Assert that endpoint responds with SUCCESS - assert.equal(resp.status, 200); - - // check that there's no longer doc api keys - const respFetch2 = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); - // Assert that there is no longer doc api keys for 'Apathy' - assert.equal(respFetch2.status, 200); - assert.equal(respFetch2.data.length, 0); - }); - - it('DELETE /api/docs/{did}/apikeys returns 403 when not owning :did', async function() { - const did = await dbManager.testGetId('Curiosity'); - - const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, nobody); - // Assert that deletion FAILS when not owning document - assert.equal(resp.status, 403); - }); - - it('DELETE /api/docs/{did}/apikeys returns 404 when deleting on non existing :did', async function() { - const did = "not-a-document"; - - const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); - // Assert that READ fails when :did not exists - assert.equal(resp.status, 404); - }); - it('GET /api/zig is a 404', async function() { const resp = await axios.get(`${homeUrl}/api/zig`, chimpy); assert.equal(resp.status, 404); @@ -2794,6 +2332,515 @@ describe('ApiServer', function() { // Assert that the response status is 404 because the templates org doesn't exist. assert.equal(resp.status, 404); }); + + describe('DocApiKey Endpoint', function(){ + let oldEnv: testUtils.EnvironmentSnapshot; + + testUtils.setTmpLogLevel('error'); + + before(async function() { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.GRIST_TEMPLATE_ORG = 'templates'; + server = new TestServer(this); + homeUrl = await server.start(['home', 'docs']); + dbManager = server.dbManager; + + chimpyRef = await dbManager.getUserByLogin(chimpyEmail).then((user) => user!.ref); + kiwiRef = await dbManager.getUserByLogin(kiwiEmail).then((user) => user!.ref); + charonRef = await dbManager.getUserByLogin(charonEmail).then((user) => user!.ref); + + // Listen to user count updates and add them to an array. + dbManager.on('userChange', ({org, countBefore, countAfter}: UserChange) => { + if (countBefore === countAfter) { return; } + userCountUpdates[org.id] = userCountUpdates[org.id] || []; + userCountUpdates[org.id].push(countAfter); + }); + }); + + afterEach(async function() { + oldEnv.restore(); + const did = await dbManager.testGetId('Curiosity'); + await dbManager.deleteDocApiKeys(did as string); + }); + + after(async function() { + }); + + it('POST /api/docs/{did}/apikey is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api is created + assert.equal(resp.status, 200); + // Assert that response matches a string in Flickr Base58 style + assert.match(resp.data, /^[1-9a-km-zA-HJ-NP-Z]{22}$/); + }); + + it('POST /api/docs/{did}/apikey returns 404 on non existing :did', async function() { + const did = 'falsedocid_12'; + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc doesn't exist + assert.equal(resp.status, 404); + }); + + it('POST /api/docs/{did}/apikey returns 400 when no options', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key creation fails when no `options` set + assert.equal(resp.status, 400); + }); + + it('POST /api/docs/{did}/apikey returns 400 when options.access is missing', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key creation fails when no `options.access` set + assert.equal(resp.status, 400); + }); + + it('POST /api/docs/{did}/apikey returns 400 when options.access have bad value', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Root"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that `options.access` value is illegal + assert.equal(resp.status, 400); + }); + + it('POST /api/docs/{did}/apikey returns 400 on arbitrary options', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor", injection: "It's a bad, bad, thing"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that `options` keys are illegal + assert.equal(resp.status, 400); + }); + + it('POST /api/docs/{did}/apikey status 400 when linkId already exists for given :did', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "Peace-And-Tranquility-2-Earth"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that docApiKey is created + assert.equal(resp1.status, 200); + + const resp2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that docApiKey with same LinKid can't be created + assert.equal(resp2.status, 400); + }); + + it('POST /api/docs/{did}/apikey returns 403 when not owning doc', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "I-am-nobody"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, nobody); + // Assert that it needs to be owner of :did to create a doc api key + console.log(resp.data) + assert.equal(resp.status, 403); + }); + + it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "Small-step-for-man"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, chimpy); + // Assert that doc api key can be read + assert.equal(fetchResp.status, 200); + // Assert that doc api key is well formed + assert.deepEqual( + omit(fetchResp.data, 'id'), + { + key: resp.data, + linkId: linkId, + docId: did, + options: { + ... options, + apikey: true, + } + } + ); + }); + + it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing did', async function() { + const did = 'falsedocid_12'; + const linkId = "Peace-And-Tranquility-2-Earth"; + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that :did doesn't exist + assert.equal(fetchResp.status, 404); + }); + + it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing linkId', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "non-existing"; + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that :linkId doesn't exist + assert.equal(fetchResp.status, 404); + }); + + it('GET /api/docs/{did}/apikey/{linkId} returns 403 when not owning :did', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access:"Editor"}; + const linkId = "For-Owners-Eyes-Only"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, nobody); + // Assert that doc api key can't be read if not owner + console.log(fetchResp.data); + assert.equal(fetchResp.status, 403); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor"}; + const linkId = "Great-step-for-humanity"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + + const newLinkId = "Ground-Control-To-Major-Tom"; + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, {linkId: newLinkId}, chimpy); + // Assert that doc api key is modified + assert.equal(patchResp.status, 200); + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${newLinkId}`, chimpy); + // Assert that doc api key can be read by its new linkId + assert.equal(fetchResp.status, 200); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 200 on options.access modification', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Viewer"}; + const newOptions = {access: "Editor"}; + const linkId = "Ground-Control-To-Major-Tom"; + + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(newOptions)}, chimpy); + // Assert that options.access is modified + assert.equal(patchResp.status, 200, "options.access is modified"); + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that modified doc api key still can be read + assert.equal(fetchResp.status, 200, "doc api key can still be read"); + assert.equal(JSON.parse(fetchResp.data.options).access, newOptions.access); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 when empty option', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = ""; + const linkId = "Ground-Control-To-Major-Tom"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options}, chimpy); + // Assert that empty option is rejected + assert.equal(patchResp.status, 400); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on bad options key', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Viewer", bad:"injection"}; + const linkId = "Ground-Control-To-Major-Tom"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(options)}, chimpy); + // Assert that bad options keys are rejected + assert.equal(patchResp.status, 400); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on illegal value of options.access', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "injection"}; + const linkId = "Ground-Control-To-Major-Tom"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(options)}, chimpy); + // Assert that options.access illegal values are rejected + assert.equal(patchResp.status, 400); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on did update', async function() { + const did = await dbManager.testGetId('Curiosity'); + const newdid = "Another-Document-Id"; + const linkId = "Ground-Control-To-Major-Tom"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {docId: newdid}, chimpy); + // Assert that options.access is modified + assert.equal(patchResp.status, 400); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on key update', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "Ground-Control-To-Major-Tom"; + const key = "ViciousKeyInjection"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {key}, chimpy); + // Assert that options.access is modified + assert.equal(patchResp.status, 400); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on options.apikey update', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {apikey: "false"}; + const linkId = "Ground-Control-To-Major-Tom"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {options: JSON.stringify(options)}, chimpy); + // Assert that options.apikey update is rejected + assert.equal(patchResp.status, 400); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 404 on did not owned', async function() { + const did = "not-a-document"; + const linkId = "Ground-Control-To-Major-Tom"; + const newLinkId = "Ground-Control-to-Major-Tim"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {linkId: newLinkId}, chimpy); + // Assert that update of a doc api key of not :did is rejected + assert.equal(patchResp.status, 404); + }); + + it('PATCH /api/docs/{did}/apikey/{linkId} returns 403 on did not owned', async function() { + const did = await dbManager.testGetId('Curiosity'); + const linkId = "Ground-Control-To-Major-Tom"; + const newLinkId = "Ground-Control-to-Major-Tim"; + + const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, + {linkId: newLinkId}, nobody); + // Assert that update of a doc api key of not :did is rejected + assert.equal(patchResp.status, 403); + }); + + it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor"}; + const linkId = "Houston-we-have-a-problem"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + + const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that doc api key can be read + assert.equal(fetchResp.status, 200); + + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that endpoint responds with SUCCESS + assert.equal(deleteResp.status, 200); + + const fetchResp2 = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that doc api key no longer exists + assert.equal(fetchResp2.status, 404); + }); + + it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :did', async function() { + const did = "not-a-document"; + const linkId = "Lucy-In-The-Sky"; + + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, chimpy); + // Assert that endpoint responds with SUCCESS + assert.equal(deleteResp.status, 404); + }); + + it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :linkId', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor"}; + const linkId = "Lucy-In-The-Sky"; + const oupsLinkId = "Mary-In-The-Sky"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${oupsLinkId}`, chimpy); + // Assert that deletion FAILS on invalid LinkId + assert.equal(deleteResp.status, 404); + }); + + it('DELETE /api/docs/{did}/apikey/{linkId} returns 403 when not owning doc', async function() { + const did = await dbManager.testGetId('Curiosity'); + const options = {access: "Editor"}; + const linkId = "With-Diamonds"; + const body = {"linkId": linkId, "options":JSON.stringify(options)}; + + const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + // Assert that doc api key is created + assert.equal(resp.status, 200); + + const deleteResp = await axios.delete(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, nobody); + // Assert that deletion FAILS when not owning doc + assert.equal(deleteResp.status, 403); + }); + + it('GET /api/docs/{did}/apikeys is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + + // Creation of the first doc-api-key + const options1 = {access: "Editor"}; + const linkId1 = "Major-Tom-To-Ground-Control"; + const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; + const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + // Assert that first doc api key is created + assert.equal(respPost1.status, 200, "First docApiKey created"); + + // Creation of the second doc-api-key + const options2 = {access: "Viewer"}; + const linkId2 = "Ground-Control-To-Major-Tom"; + const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; + const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + // Assert that second doc api key is created + assert.equal(respPost2.status, 200, "Second docApiKeyCreated"); + + // Get doc-api-keys + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that endpoint reponds with SUCCESS + assert.equal(resp.status, 200, "Get docApiKeys"); + + // Assert there's two doc api keys for this 'Curiosity' + assert.equal(resp.data.length, 2, "There is two docApiKeys"); + }); + + it('GET /api/docs/{did}/apikeys returns 403 when not owning :did ', async function() { + const did = await dbManager.testGetId('Curiosity'); + + // Creation of the first doc-api-key + const options1 = {access: "Editor"}; + const linkId1 = "Major-Tim-To-Ground-Control"; + const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; + const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + // Assert that first doc api key is created + assert.equal(respPost1.status, 200); + + // Creation of the second doc-api-key + const options2 = {access: "Viewer"}; + const linkId2 = "Ground-Control-To-Major-Tim"; + const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; + const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + // Assert that second doc api key is created + assert.equal(respPost2.status, 200); + + // Get doc-api-keys + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, nobody); + // Assert that READ fails when not owning :did + assert.equal(resp.status, 403); + }); + + it('GET /api/docs/{did}/apikeys returns 404 on not existing :did', async function() { + const did = "not-a-document"; + + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that READ fails when :did not exists + assert.equal(resp.status, 404); + }); + + it('GET /api/docs/{did}/apikeys returns 200 and empty array when no keys', async function() { + const did = await dbManager.testGetId('Apathy'); + + const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that READ is succeffull + assert.equal(resp.status, 200); + // Assertt that data is an empty list + assert.equal(resp.data.length, 0); + }); + + it('DELETE /api/docs/{did}/apikeys is operational', async function() { + const did = await dbManager.testGetId('Apathy'); + + // Creation of the first doc-api-key + const options1 = {access:"Editor"}; + const linkId1 = "Peace-And-Tranquility-2-Earth"; + const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; + const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); + // Assert that first doc api key is created + assert.equal(respPost1.status, 200); + + // Creation of the second doc-api-key + const options2 = {access:"Viewer"}; + const linkId2 = "Ground-Control-4-Major-Tom"; + const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; + const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); + // Assert that second doc api key is created + assert.equal(respPost2.status, 200); + + // Get doc-api-keys + const respFetch = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that endpoint responds with SUCCESS + assert.equal(respFetch.status, 200); + // Assert there's two doc api keys for this 'Apathy' + assert.equal(respFetch.data.length, 2); + + // Delete all doc api keys for 'Apathy' + const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that endpoint responds with SUCCESS + assert.equal(resp.status, 200); + + // check that there's no longer doc api keys + const respFetch2 = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that there is no longer doc api keys for 'Apathy' + assert.equal(respFetch2.status, 200); + assert.equal(respFetch2.data.length, 0); + }); + + it('DELETE /api/docs/{did}/apikeys returns 403 when not owning :did', async function() { + const did = await dbManager.testGetId('Curiosity'); + + const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, nobody); + // Assert that deletion FAILS when not owning document + assert.equal(resp.status, 403); + }); + + it('DELETE /api/docs/{did}/apikeys returns 404 when deleting on non existing :did', async function() { + const did = "not-a-document"; + + const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); + // Assert that READ fails when :did not exists + assert.equal(resp.status, 404); + }); + }) }); From 798234fbe943e69c4dbc8781ae34ebdb4d6f23cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Wed, 10 Jul 2024 14:34:58 +0200 Subject: [PATCH 19/31] Chore: Endpoints Doc Api Key DONE --- test/gen-server/ApiServer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index d3eb9679e4..c1a6197a9e 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -2456,7 +2456,6 @@ describe('ApiServer', function() { const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, nobody); // Assert that it needs to be owner of :did to create a doc api key - console.log(resp.data) assert.equal(resp.status, 403); }); @@ -2518,7 +2517,6 @@ describe('ApiServer', function() { const fetchResp = await axios.get(`${homeUrl}/api/docs/${did}/apikey/${body.linkId}`, nobody); // Assert that doc api key can't be read if not owner - console.log(fetchResp.data); assert.equal(fetchResp.status, 403); }); @@ -2840,7 +2838,7 @@ describe('ApiServer', function() { // Assert that READ fails when :did not exists assert.equal(resp.status, 404); }); - }) + }); }); From d7d4c7448d8c6f37a119a388da6f3bc8042bf181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 11 Jul 2024 15:44:29 +0200 Subject: [PATCH 20/31] fix: repect ShareOptions.access authorized values in type declaration --- app/gen-server/ApiServer.ts | 8 ++++---- test/gen-server/ApiServer.ts | 40 ++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 2925325179..83cc88f758 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -814,8 +814,8 @@ async function updateApiKeyWithRetry(manager: EntityManager, user: User): Promis } function sanitizeDocApiKeyOptions(rawOptions: string): ShareOptions { - const legalOptions = ["access"]; - const legalAccessValues = ["Editor", "Viewer"]; + const legalOptions: (keyof ShareOptions)[] = ["access"]; + const legalAccessValues: ShareOptions["access"][] = ["editors", "viewers"]; if (!rawOptions){ throw new ApiError("Missing body params: options", 400); @@ -827,8 +827,8 @@ function sanitizeDocApiKeyOptions(rawOptions: string): ShareOptions { } Object.keys(options).forEach(element => { - if (!legalOptions.includes(element)){ - throw new ApiError("Invalid option: ${element}", 400); + if (!(legalOptions as string[]).includes(element)){ + throw new ApiError(`Invalid option: ${element}`, 400); } }); diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index c1a6197a9e..56b77eb571 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -2368,7 +2368,7 @@ describe('ApiServer', function() { it('POST /api/docs/{did}/apikey is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; + const options = {access: "editors"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2381,7 +2381,7 @@ describe('ApiServer', function() { it('POST /api/docs/{did}/apikey returns 404 on non existing :did', async function() { const did = 'falsedocid_12'; - const options = {access:"Editor"}; + const options = {access: "editors"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2424,7 +2424,7 @@ describe('ApiServer', function() { it('POST /api/docs/{did}/apikey returns 400 on arbitrary options', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor", injection: "It's a bad, bad, thing"}; + const options = {access: "editors", injection: "It's a bad, bad, thing"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2435,7 +2435,7 @@ describe('ApiServer', function() { it('POST /api/docs/{did}/apikey status 400 when linkId already exists for given :did', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; + const options = {access: "editors"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2450,7 +2450,7 @@ describe('ApiServer', function() { it('POST /api/docs/{did}/apikey returns 403 when not owning doc', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; + const options = {access: "editors"}; const linkId = "I-am-nobody"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2461,7 +2461,7 @@ describe('ApiServer', function() { it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; + const options = {access: "editors"}; const linkId = "Small-step-for-man"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2507,7 +2507,7 @@ describe('ApiServer', function() { it('GET /api/docs/{did}/apikey/{linkId} returns 403 when not owning :did', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Editor"}; + const options = {access: "editors"}; const linkId = "For-Owners-Eyes-Only"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2522,7 +2522,7 @@ describe('ApiServer', function() { it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; + const options = {access: "editors"}; const linkId = "Great-step-for-humanity"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2542,8 +2542,8 @@ describe('ApiServer', function() { it('PATCH /api/docs/{did}/apikey/{linkId} returns 200 on options.access modification', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Viewer"}; - const newOptions = {access: "Editor"}; + const options = {access: "viewers"}; + const newOptions = {access: "editors"}; const linkId = "Ground-Control-To-Major-Tom"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2576,7 +2576,7 @@ describe('ApiServer', function() { it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on bad options key', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Viewer", bad:"injection"}; + const options = {access: "viewers", bad: "injection"}; const linkId = "Ground-Control-To-Major-Tom"; const patchResp = await axios.patch(`${homeUrl}/api/docs/${did}/apikey/${linkId}`, @@ -2653,7 +2653,7 @@ describe('ApiServer', function() { it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; + const options = {access: "editors"}; const linkId = "Houston-we-have-a-problem"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2685,7 +2685,7 @@ describe('ApiServer', function() { it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :linkId', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; + const options = {access: "editors"}; const linkId = "Lucy-In-The-Sky"; const oupsLinkId = "Mary-In-The-Sky"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2701,7 +2701,7 @@ describe('ApiServer', function() { it('DELETE /api/docs/{did}/apikey/{linkId} returns 403 when not owning doc', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access: "Editor"}; + const options = {access: "editors"}; const linkId = "With-Diamonds"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; @@ -2718,7 +2718,7 @@ describe('ApiServer', function() { const did = await dbManager.testGetId('Curiosity'); // Creation of the first doc-api-key - const options1 = {access: "Editor"}; + const options1 = {access: "editors"}; const linkId1 = "Major-Tom-To-Ground-Control"; const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); @@ -2726,7 +2726,7 @@ describe('ApiServer', function() { assert.equal(respPost1.status, 200, "First docApiKey created"); // Creation of the second doc-api-key - const options2 = {access: "Viewer"}; + const options2 = {access: "viewers"}; const linkId2 = "Ground-Control-To-Major-Tom"; const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); @@ -2746,7 +2746,7 @@ describe('ApiServer', function() { const did = await dbManager.testGetId('Curiosity'); // Creation of the first doc-api-key - const options1 = {access: "Editor"}; + const options1 = {access: "editors"}; const linkId1 = "Major-Tim-To-Ground-Control"; const body1 = {"linkId": linkId1, "options": JSON.stringify(options1)}; const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); @@ -2754,7 +2754,7 @@ describe('ApiServer', function() { assert.equal(respPost1.status, 200); // Creation of the second doc-api-key - const options2 = {access: "Viewer"}; + const options2 = {access: "viewers"}; const linkId2 = "Ground-Control-To-Major-Tim"; const body2 = {"linkId": linkId2, "options": JSON.stringify(options2)}; const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); @@ -2789,7 +2789,7 @@ describe('ApiServer', function() { const did = await dbManager.testGetId('Apathy'); // Creation of the first doc-api-key - const options1 = {access:"Editor"}; + const options1 = {access: "editors"}; const linkId1 = "Peace-And-Tranquility-2-Earth"; const body1 = {"linkId": linkId1, "options":JSON.stringify(options1)}; const respPost1 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body1, chimpy); @@ -2797,7 +2797,7 @@ describe('ApiServer', function() { assert.equal(respPost1.status, 200); // Creation of the second doc-api-key - const options2 = {access:"Viewer"}; + const options2 = {access: "viewers"}; const linkId2 = "Ground-Control-4-Major-Tom"; const body2 = {"linkId": linkId2, "options":JSON.stringify(options2)}; const respPost2 = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body2, chimpy); From 9e779f95399d37eb55e8b46b755deb2d42ca72a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 11 Jul 2024 15:46:02 +0200 Subject: [PATCH 21/31] feat: docApikey as ApiKey WIP --- app/gen-server/lib/homedb/HomeDBManager.ts | 4 ++ app/server/lib/Authorizer.ts | 58 ++++++++++++++++------ 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index b4b5f1b02f..f0c7088d08 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -2507,6 +2507,10 @@ export class HomeDBManager extends EventEmitter { .getOne() || undefined; } + public async getDocApiKeyByKey(key: string): Promise { + return await this.getShareByKey(key); + } + public async createDocApiKey(docId: string, share: ShareInfo) { const key = makeId(); const query = await this._connection.createQueryBuilder() diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 0386a6d304..f60e595f0d 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -173,23 +173,53 @@ export async function addRequestUser( // Now, check for an apiKey if (!authDone && mreq.headers && mreq.headers.authorization) { - // header needs to be of form "Bearer XXXXXXXXX" to apply const parts = String(mreq.headers.authorization).split(' '); + // header needs to be of form "Bearer XXXXXXXXX" to apply if (parts[0] === "Bearer") { - const user = parts[1] ? await dbManager.getUserByKey(parts[1]) : undefined; - if (!user) { - return res.status(401).send('Bad request: invalid API key'); - } - if (user.id === dbManager.getAnonymousUserId()) { - // We forbid the anonymous user to present an api key. That saves us - // having to think through the consequences of authorized access to the - // anonymous user's profile via the api (e.g. how should the api key be managed). - return res.status(401).send('Credentials cannot be presented for the anonymous user account via API key'); + // Bearer needs to be form "DOC-YYYYYYYYY" to apply as Doc Api key + if (parts[1].match(/^DOC-.*/)) { + // Doc Api Key + const docApiKey = parts[1].split('-')[1]; + const share = docApiKey ? await dbManager.getDocApiKeyByKey(docApiKey) : undefined; + // A share with key matching Bearer and having options.access set to true MUST exist + if (!share || !share.options.apikey) { + return res.status(401).send('Bad request: invalid Doc Api key'); + } + const did = mreq.params.did; + // DocId in request parameters MUST match share.DocId + if (did !== share.docId){ + return res.status(401).send('Bad request: invalid Doc Api key'); + } + const url = new URL(mreq.url); + const regex = new RegExp(String.raw`\s^\/api\/docs\/${did}.*\s`, "g"); + // Only API scope matching regex MUST be accessed with Doc Api keys + if (!url.pathname.match(regex)){ + return res.status(401).send(`Access Denied: Scope limited to Document ${did}`); + } + // Viewers can only access with GET methods + if (share.options.access === "viewers" && "GET" !== mreq.method){ + return res.send(401).send(`Access Denied: invalid method for viewers - ${mreq.method}`); + } + hasApiKey = true; + return next(); + // If Bearer have another form it must be a User Api Key + } else { + // full scope Api Key + const user = parts[1] ? await dbManager.getUserByKey(parts[1]) : undefined; + if (!user) { + return res.status(401).send('Bad request: invalid API key'); + } + if (user.id === dbManager.getAnonymousUserId()) { + // We forbid the anonymous user to present an api key. That saves us + // having to think through the consequences of authorized access to the + // anonymous user's profile via the api (e.g. how should the api key be managed). + return res.status(401).send('Credentials cannot be presented for the anonymous user account via API key'); + } + mreq.user = user; + mreq.userId = user.id; + mreq.userIsAuthorized = true; + hasApiKey = true; } - mreq.user = user; - mreq.userId = user.id; - mreq.userIsAuthorized = true; - hasApiKey = true; } } From b5e2fecd7eb1bee8d56bc991252f4a1f10637009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 11 Jul 2024 15:46:38 +0200 Subject: [PATCH 22/31] fix: typos Missing spaces --- test/gen-server/ApiServer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index 56b77eb571..86f22b25bc 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -2333,7 +2333,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 404); }); - describe('DocApiKey Endpoint', function(){ + describe('DocApiKey Endpoint', function() { let oldEnv: testUtils.EnvironmentSnapshot; testUtils.setTmpLogLevel('error'); @@ -2404,7 +2404,7 @@ describe('ApiServer', function() { const did = await dbManager.testGetId('Curiosity'); const options = {}; const linkId = "Peace-And-Tranquility-2-Earth"; - const body = {"linkId": linkId, "options":JSON.stringify(options)}; + const body = {"linkId": linkId, "options": JSON.stringify(options)}; const resp = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); // Assert that doc api key creation fails when no `options.access` set @@ -2413,7 +2413,7 @@ describe('ApiServer', function() { it('POST /api/docs/{did}/apikey returns 400 when options.access have bad value', async function() { const did = await dbManager.testGetId('Curiosity'); - const options = {access:"Root"}; + const options = {access: "Root"}; const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId, "options":JSON.stringify(options)}; From a713ec4ae939fd12b60c2f117c55f858951fafd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 15 Jul 2024 11:30:58 +0200 Subject: [PATCH 23/31] feat: test DocApiKey as ApiKey 1/5 --- test/gen-server/ApiServer.ts | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index 86f22b25bc..f1a6819b9a 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -2838,10 +2838,44 @@ describe('ApiServer', function() { // Assert that READ fails when :did not exists assert.equal(resp.status, 404); }); + + it('used as ApiKey is operational', async function() { + const did = await dbManager.testGetId('Curiosity'); + + // Creation of the first doc-api-key + const options = {access: "editors"}; + const linkId = "Major-Tom-To-Ground-Control"; + const body = {"linkId": linkId, "options": JSON.stringify(options)}; + const respPost = await axios.post(`${homeUrl}/api/docs/${did}/apikey`, body, chimpy); + const docApiKey = respPost.data; + // Assert that first doc api key is created + assert.equal(respPost.status, 200, "docApiKey created"); + + // Make a call to GET /api/doc/:docId + const authorizationHeader = `Bearer DOC-${docApiKey}`; + const anonymous = {...nobody, authorizationHeader}; + const fetch = await axios.get(`${homeUrl}/api/docs/${did}`, anonymous); + assert.equal(fetch.status, 200); + }); + + it('must fail when doApiKey is wrong', async function() { + + }); + + it("can't access scopes outside /api/docs", async function() { + + }); + + it('can use all HTTP verbs with editors access', async function() { + + }); + + it('only can use GET HTTP verb with veiwers access', async function() { + + }); }); }); - // Predict the next id that will be used for a table. // Only reliable if we haven't been deleting records in that table. // Could make reliable by using sqlite_sequence in sqlite and the equivalent From 1c6c734c4a26ed4c2d72857a935a1625f0f8e212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Thu, 18 Jul 2024 21:29:22 +0200 Subject: [PATCH 24/31] feat: wip docApiKey Road to end --- app/gen-server/lib/homedb/HomeDBManager.ts | 11 ++++ app/server/lib/Authorizer.ts | 34 +++++++--- test/gen-server/ApiServer.ts | 73 +++++++++++----------- 3 files changed, 75 insertions(+), 43 deletions(-) diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index f0c7088d08..bb958028c5 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -968,6 +968,17 @@ export class HomeDBManager extends EventEmitter { return doc; } + public async getOrgOwnerFromDocApiKey(docApiKey: Share) { + return this._connection.createQueryBuilder() + .select('orgs') + .from(Organization, 'orgs') + .leftJoin(Workspace, 'workspaces', 'orgs.id = workspaces.org_id') + .leftJoin(Document, 'docs', 'workspaces.id = docs.workspace_id') + .leftJoin(Share, 'shares', 'docs.id = shares.doc_id') + .where('shares.key = :key', {key: 'uKXGYSc5pZ2muaQP9WKgpY'}) + .getOne(); + } + // Calls getDocImpl() and returns the Document from that, caching a fresh DocAuthResult along // the way. Note that we only cache the access level, not Document itself. public async getDoc(reqOrScope: Request | Scope, transaction?: EntityManager): Promise { diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index f60e595f0d..825dc22de8 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -23,6 +23,7 @@ import * as cookie from 'cookie'; import {NextFunction, Request, RequestHandler, Response} from 'express'; import {IncomingMessage} from 'http'; import onHeaders from 'on-headers'; +import { ShareOptions } from 'app/common/ShareOptions'; export interface RequestWithLogin extends Request { sessionID: string; @@ -178,29 +179,48 @@ export async function addRequestUser( if (parts[0] === "Bearer") { // Bearer needs to be form "DOC-YYYYYYYYY" to apply as Doc Api key if (parts[1].match(/^DOC-.*/)) { + const url = mreq.url; + + const didRegex = /(?<=^\/docs\/).+?(?=\/|$)/g; + const did = url.match(didRegex); + // Only API scope matching regex MUST be accessed with Doc Api keys + if (!did || !did[0]){ + return res.status(401).send(`Access Denied: Scope limited to Documents`); + } // Doc Api Key const docApiKey = parts[1].split('-')[1]; const share = docApiKey ? await dbManager.getDocApiKeyByKey(docApiKey) : undefined; + // A share with key matching Bearer and having options.access set to true MUST exist if (!share || !share.options.apikey) { return res.status(401).send('Bad request: invalid Doc Api key'); } - const did = mreq.params.did; // DocId in request parameters MUST match share.DocId - if (did !== share.docId){ + if (did[0] !== share.docId){ return res.status(401).send('Bad request: invalid Doc Api key'); } - const url = new URL(mreq.url); - const regex = new RegExp(String.raw`\s^\/api\/docs\/${did}.*\s`, "g"); - // Only API scope matching regex MUST be accessed with Doc Api keys - if (!url.pathname.match(regex)){ - return res.status(401).send(`Access Denied: Scope limited to Document ${did}`); + // access as two valid values + const legalAccessValues: ShareOptions["access"][] = ["editors", "viewers"]; + if (!legalAccessValues.includes(share.options.access)){ + return res.status(401).send(`Bad request: invalid option.access ${share.options.access}`); } // Viewers can only access with GET methods if (share.options.access === "viewers" && "GET" !== mreq.method){ return res.send(401).send(`Access Denied: invalid method for viewers - ${mreq.method}`); } + + // We setup the user as anonymous + // with access determined by share.options.access value + const anon = dbManager.getAnonymousUser(); + mreq.user = anon; + const org = await dbManager.getOrgOwnerFromDocApiKey(share); + const userId = org?.ownerId; + mreq.userId = userId; + mreq.userIsAuthorized = true; hasApiKey = true; + const access = share.options.access ? share.options.access : "viewers"; + mreq.docAuth = {access, docId:did[0], removed: null}; + return next(); // If Bearer have another form it must be a User Api Key } else { diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index f1a6819b9a..d7184629bb 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -2333,7 +2333,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 404); }); - describe('DocApiKey Endpoint', function() { + describe('DocApiKey', function() { let oldEnv: testUtils.EnvironmentSnapshot; testUtils.setTmpLogLevel('error'); @@ -2364,9 +2364,10 @@ describe('ApiServer', function() { }); after(async function() { + await server.stop(); }); - it('POST /api/docs/{did}/apikey is operational', async function() { + it('Endpoint POST /api/docs/{did}/apikey is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "Peace-And-Tranquility-2-Earth"; @@ -2379,7 +2380,7 @@ describe('ApiServer', function() { assert.match(resp.data, /^[1-9a-km-zA-HJ-NP-Z]{22}$/); }); - it('POST /api/docs/{did}/apikey returns 404 on non existing :did', async function() { + it('Endpoint POST /api/docs/{did}/apikey returns 404 on non existing :did', async function() { const did = 'falsedocid_12'; const options = {access: "editors"}; const linkId = "Peace-And-Tranquility-2-Earth"; @@ -2390,7 +2391,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 404); }); - it('POST /api/docs/{did}/apikey returns 400 when no options', async function() { + it('Endpoint POST /api/docs/{did}/apikey returns 400 when no options', async function() { const did = await dbManager.testGetId('Curiosity'); const linkId = "Peace-And-Tranquility-2-Earth"; const body = {"linkId": linkId}; @@ -2400,7 +2401,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 400); }); - it('POST /api/docs/{did}/apikey returns 400 when options.access is missing', async function() { + it('Endpoint POST /api/docs/{did}/apikey returns 400 when options.access is missing', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {}; const linkId = "Peace-And-Tranquility-2-Earth"; @@ -2411,7 +2412,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 400); }); - it('POST /api/docs/{did}/apikey returns 400 when options.access have bad value', async function() { + it('Endpoint POST /api/docs/{did}/apikey returns 400 when options.access have bad value', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "Root"}; const linkId = "Peace-And-Tranquility-2-Earth"; @@ -2422,7 +2423,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 400); }); - it('POST /api/docs/{did}/apikey returns 400 on arbitrary options', async function() { + it('Endpoint POST /api/docs/{did}/apikey returns 400 on arbitrary options', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors", injection: "It's a bad, bad, thing"}; const linkId = "Peace-And-Tranquility-2-Earth"; @@ -2433,7 +2434,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 400); }); - it('POST /api/docs/{did}/apikey status 400 when linkId already exists for given :did', async function() { + it('Endpoint POST /api/docs/{did}/apikey status 400 when linkId already exists for given :did', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "Peace-And-Tranquility-2-Earth"; @@ -2448,7 +2449,7 @@ describe('ApiServer', function() { assert.equal(resp2.status, 400); }); - it('POST /api/docs/{did}/apikey returns 403 when not owning doc', async function() { + it('Endpoint POST /api/docs/{did}/apikey returns 403 when not owning doc', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "I-am-nobody"; @@ -2459,7 +2460,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 403); }); - it('GET /api/docs/{did}/apikey/{linkId} is operational', async function() { + it('Endpoint GET /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "Small-step-for-man"; @@ -2487,7 +2488,7 @@ describe('ApiServer', function() { ); }); - it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing did', async function() { + it('Endpoint GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing did', async function() { const did = 'falsedocid_12'; const linkId = "Peace-And-Tranquility-2-Earth"; @@ -2496,7 +2497,7 @@ describe('ApiServer', function() { assert.equal(fetchResp.status, 404); }); - it('GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing linkId', async function() { + it('Endpoint GET /api/docs/{did}/apikey/{linkId} returns 404 on non existing linkId', async function() { const did = await dbManager.testGetId('Curiosity'); const linkId = "non-existing"; @@ -2505,7 +2506,7 @@ describe('ApiServer', function() { assert.equal(fetchResp.status, 404); }); - it('GET /api/docs/{did}/apikey/{linkId} returns 403 when not owning :did', async function() { + it('Endpoint GET /api/docs/{did}/apikey/{linkId} returns 403 when not owning :did', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "For-Owners-Eyes-Only"; @@ -2520,7 +2521,7 @@ describe('ApiServer', function() { assert.equal(fetchResp.status, 403); }); - it('PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "Great-step-for-humanity"; @@ -2540,7 +2541,7 @@ describe('ApiServer', function() { assert.equal(fetchResp.status, 200); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 200 on options.access modification', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 200 on options.access modification', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "viewers"}; const newOptions = {access: "editors"}; @@ -2563,7 +2564,7 @@ describe('ApiServer', function() { assert.equal(JSON.parse(fetchResp.data.options).access, newOptions.access); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 when empty option', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 400 when empty option', async function() { const did = await dbManager.testGetId('Curiosity'); const options = ""; const linkId = "Ground-Control-To-Major-Tom"; @@ -2574,7 +2575,7 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 400); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on bad options key', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 400 on bad options key', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "viewers", bad: "injection"}; const linkId = "Ground-Control-To-Major-Tom"; @@ -2585,8 +2586,8 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 400); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on illegal value of options.access', async function() { - const did = await dbManager.testGetId('Curiosity'); + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 400 on illegal value of opts.access', async function() { + const did = await dbManager.testGetId('Curiosity'); const options = {access: "injection"}; const linkId = "Ground-Control-To-Major-Tom"; @@ -2596,7 +2597,7 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 400); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on did update', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 400 on did update', async function() { const did = await dbManager.testGetId('Curiosity'); const newdid = "Another-Document-Id"; const linkId = "Ground-Control-To-Major-Tom"; @@ -2607,7 +2608,7 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 400); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on key update', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 400 on key update', async function() { const did = await dbManager.testGetId('Curiosity'); const linkId = "Ground-Control-To-Major-Tom"; const key = "ViciousKeyInjection"; @@ -2618,7 +2619,7 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 400); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 400 on options.apikey update', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 400 on options.apikey update', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {apikey: "false"}; const linkId = "Ground-Control-To-Major-Tom"; @@ -2629,7 +2630,7 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 400); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 404 on did not owned', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 404 on did not owned', async function() { const did = "not-a-document"; const linkId = "Ground-Control-To-Major-Tom"; const newLinkId = "Ground-Control-to-Major-Tim"; @@ -2640,7 +2641,7 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 404); }); - it('PATCH /api/docs/{did}/apikey/{linkId} returns 403 on did not owned', async function() { + it('Endpoint PATCH /api/docs/{did}/apikey/{linkId} returns 403 on did not owned', async function() { const did = await dbManager.testGetId('Curiosity'); const linkId = "Ground-Control-To-Major-Tom"; const newLinkId = "Ground-Control-to-Major-Tim"; @@ -2651,7 +2652,7 @@ describe('ApiServer', function() { assert.equal(patchResp.status, 403); }); - it('DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { + it('Endpoint DELETE /api/docs/{did}/apikey/{linkId} is operational', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "Houston-we-have-a-problem"; @@ -2674,7 +2675,7 @@ describe('ApiServer', function() { assert.equal(fetchResp2.status, 404); }); - it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :did', async function() { + it('Endpoint DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :did', async function() { const did = "not-a-document"; const linkId = "Lucy-In-The-Sky"; @@ -2683,7 +2684,7 @@ describe('ApiServer', function() { assert.equal(deleteResp.status, 404); }); - it('DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :linkId', async function() { + it('Endpoint DELETE /api/docs/{did}/apikey/{linkId} returns 404 on non existing :linkId', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "Lucy-In-The-Sky"; @@ -2699,7 +2700,7 @@ describe('ApiServer', function() { assert.equal(deleteResp.status, 404); }); - it('DELETE /api/docs/{did}/apikey/{linkId} returns 403 when not owning doc', async function() { + it('Endpoint DELETE /api/docs/{did}/apikey/{linkId} returns 403 when not owning doc', async function() { const did = await dbManager.testGetId('Curiosity'); const options = {access: "editors"}; const linkId = "With-Diamonds"; @@ -2714,7 +2715,7 @@ describe('ApiServer', function() { assert.equal(deleteResp.status, 403); }); - it('GET /api/docs/{did}/apikeys is operational', async function() { + it('Endpoint GET /api/docs/{did}/apikeys is operational', async function() { const did = await dbManager.testGetId('Curiosity'); // Creation of the first doc-api-key @@ -2742,7 +2743,7 @@ describe('ApiServer', function() { assert.equal(resp.data.length, 2, "There is two docApiKeys"); }); - it('GET /api/docs/{did}/apikeys returns 403 when not owning :did ', async function() { + it('Endpoint GET /api/docs/{did}/apikeys returns 403 when not owning :did ', async function() { const did = await dbManager.testGetId('Curiosity'); // Creation of the first doc-api-key @@ -2767,7 +2768,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 403); }); - it('GET /api/docs/{did}/apikeys returns 404 on not existing :did', async function() { + it('Endpoint GET /api/docs/{did}/apikeys returns 404 on not existing :did', async function() { const did = "not-a-document"; const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); @@ -2775,7 +2776,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 404); }); - it('GET /api/docs/{did}/apikeys returns 200 and empty array when no keys', async function() { + it('Endpoint GET /api/docs/{did}/apikeys returns 200 and empty array when no keys', async function() { const did = await dbManager.testGetId('Apathy'); const resp = await axios.get(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); @@ -2785,7 +2786,7 @@ describe('ApiServer', function() { assert.equal(resp.data.length, 0); }); - it('DELETE /api/docs/{did}/apikeys is operational', async function() { + it('Endpoint DELETE /api/docs/{did}/apikeys is operational', async function() { const did = await dbManager.testGetId('Apathy'); // Creation of the first doc-api-key @@ -2823,7 +2824,7 @@ describe('ApiServer', function() { assert.equal(respFetch2.data.length, 0); }); - it('DELETE /api/docs/{did}/apikeys returns 403 when not owning :did', async function() { + it('Endpoint DELETE /api/docs/{did}/apikeys returns 403 when not owning :did', async function() { const did = await dbManager.testGetId('Curiosity'); const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, nobody); @@ -2831,7 +2832,7 @@ describe('ApiServer', function() { assert.equal(resp.status, 403); }); - it('DELETE /api/docs/{did}/apikeys returns 404 when deleting on non existing :did', async function() { + it('Endpoint DELETE /api/docs/{did}/apikeys returns 404 when deleting on non existing :did', async function() { const did = "not-a-document"; const resp = await axios.delete(`${homeUrl}/api/docs/${did}/apikeys`, chimpy); @@ -2853,7 +2854,7 @@ describe('ApiServer', function() { // Make a call to GET /api/doc/:docId const authorizationHeader = `Bearer DOC-${docApiKey}`; - const anonymous = {...nobody, authorizationHeader}; + const anonymous = {...nobody, Authorization : authorizationHeader}; const fetch = await axios.get(`${homeUrl}/api/docs/${did}`, anonymous); assert.equal(fetch.status, 200); }); From 3cda9e8cb2c720492789f2f09b1d13a2cc05ad9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 22 Jul 2024 10:28:34 +0200 Subject: [PATCH 25/31] feat: DocApiKey as valid ApiKey --- app/server/lib/Authorizer.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 825dc22de8..32c0b4d0bb 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -204,24 +204,20 @@ export async function addRequestUser( if (!legalAccessValues.includes(share.options.access)){ return res.status(401).send(`Bad request: invalid option.access ${share.options.access}`); } - // Viewers can only access with GET methods - if (share.options.access === "viewers" && "GET" !== mreq.method){ - return res.send(401).send(`Access Denied: invalid method for viewers - ${mreq.method}`); - } // We setup the user as anonymous // with access determined by share.options.access value const anon = dbManager.getAnonymousUser(); - mreq.user = anon; const org = await dbManager.getOrgOwnerFromDocApiKey(share); const userId = org?.ownerId; + const docAuth = await getOrSetDocAuth(mreq, dbManager, options.gristServer, did[0]); + const access = share.options.access ? share.options.access : null; + mreq.user = anon; mreq.userId = userId; mreq.userIsAuthorized = true; + mreq.docAuth = {...docAuth, access}; hasApiKey = true; - const access = share.options.access ? share.options.access : "viewers"; - mreq.docAuth = {access, docId:did[0], removed: null}; - return next(); // If Bearer have another form it must be a User Api Key } else { // full scope Api Key From 41b89ffa4d078de9cfd9e75d86d515f138e519b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 22 Jul 2024 10:38:19 +0200 Subject: [PATCH 26/31] fix: Remove from frontend --- app/client/ui/DocumentSettings.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index 6e1c2acd13..f78fff2670 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -20,7 +20,6 @@ import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; -import {DocApiKey} from 'app/client/ui/DocApiKey'; import {select} from 'app/client/ui2018/menus'; import {confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal} from 'app/client/ui2018/modals'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; @@ -39,7 +38,6 @@ const testId = makeTestId('test-settings-'); export class DocSettingsPage extends Disposable { private _docInfo = this._gristDoc.docInfo; - private _docApiKey = Observable.create(this, ''); private _timezone = this._docInfo.timezone; private _locale: KoSaveableObservable = this._docInfo.documentSettingsJson.prop('locale'); private _currency: KoSaveableObservable = this._docInfo.documentSettingsJson.prop('currency'); @@ -188,12 +186,6 @@ export class DocSettingsPage extends Disposable { href: getApiConsoleLink(docPageModel), }), }), - dom.create(DocApiKey, { - docApiKey: this._docApiKey, - onCreate: () => this._createDocApiKey(), - onDelete: () => this._deleteDocApiKey(), - inputArgs: [{size: '5'}], // Lower size so that input can shrink below ~152px. - }), dom.create(AdminSectionItem, { id: 'webhooks', name: t('Webhooks'), @@ -303,15 +295,6 @@ export class DocSettingsPage extends Disposable { await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); } } - - private async _createDocApiKey() { - // this._docApiKey.set(await this._appModel.api.createApiKey()); - } - - private async _deleteDocApiKey() { - // await this._appModel.api.deleteApiKey(); - this._docApiKey.set(''); - } } From e5e049e57bd98642c56a8e6ac0cccf25656bfaf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 22 Jul 2024 10:39:32 +0200 Subject: [PATCH 27/31] fix: Remove from frontend --- app/client/ui/DocumentSettings.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index f78fff2670..b2c9ba287b 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -186,6 +186,7 @@ export class DocSettingsPage extends Disposable { href: getApiConsoleLink(docPageModel), }), }), + dom.create(AdminSectionItem, { id: 'webhooks', name: t('Webhooks'), From 766a3285da456fbcbe07b161f58d640110c41333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Mon, 22 Jul 2024 13:50:38 +0200 Subject: [PATCH 28/31] fix: fflorent review adjustements --- app/gen-server/lib/homedb/HomeDBManager.ts | 118 +++-------------- app/gen-server/lib/homedb/SharesManager.ts | 139 +++++++++++++++++++++ app/server/lib/Authorizer.ts | 7 +- 3 files changed, 163 insertions(+), 101 deletions(-) create mode 100644 app/gen-server/lib/homedb/SharesManager.ts diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index bb958028c5..3c0bd5af14 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -44,6 +44,7 @@ import { AvailableUsers, GetUserOptions, NonGuestGroup, Resource, UserProfileChange } from 'app/gen-server/lib/homedb/Interfaces'; import {SUPPORT_EMAIL, UsersManager} from 'app/gen-server/lib/homedb/UsersManager'; +import {SharesManager} from 'app/gen-server/lib/homedb/SharesManager'; import {Permissions} from 'app/gen-server/lib/Permissions'; import {scrubUserFromOrg} from "app/gen-server/lib/scrubUserFromOrg"; import {applyPatch} from 'app/gen-server/lib/TypeORMPatches'; @@ -247,6 +248,7 @@ export type BillingOptions = Partial { - return await this._connection.createQueryBuilder() - .select('shares') - .from(Share, 'shares') - .where('shares.doc_id = :docId', {docId}) - .andWhere('shares.link_id = :linkId', {linkId}) - .getOne() || undefined; + public getDocApiKeyByLinkId(docId: string, linkId: string): Promise { + return this._sharesManager.getShareByLinkId(docId, linkId); } - public async getDocApiKeyByKey(key: string): Promise { - return await this.getShareByKey(key); + public getDocApiKeyByKey(key: string): Promise { + return this._sharesManager.getShareByKey(key); } public async createDocApiKey(docId: string, share: ShareInfo) { - const key = makeId(); - const query = await this._connection.createQueryBuilder() - .insert() - .setParameter('options', share.options) - .into(Share) - .values({ - linkId: share.linkId, - docId: docId, - options: JSON.parse(share.options), - key, - }) - .execute() || undefined; - return query ? key : query; + return this._sharesManager.createDocApiKey(docId, share); } - // in parameters linkId is the linkId in db in case of update of this id in the share + // nb. The linkId parameter corresponds to the one currently stored in the db. + // If we want to update it the new value must be passed through the share parameter public async updateDocApiKeyByLinkId(docId: string, linkId: string, share: ShareInfo) { - return await this._connection.createQueryBuilder() - .update(Share) - .set(share) - .where('doc_id = :docId and link_id = :linkId', {docId, linkId}) - .execute() || undefined; + return this._sharesManager.updateDocApiKeyByLinkId(docId, linkId, share); } public async updateDocApiKeyByKey(docId: string, apiKey: string, share: ShareInfo) { - return await this._connection.createQueryBuilder() - .update(Share) - .set(share) - .where('doc_id = :docId and key = :apiKey', {docId, apiKey}) - .execute() || undefined; + return this._sharesManager.updateDocApiKeyByKey(docId, apiKey, share); } public async deleteDocApiKeyByKey(docId: string, apiKey: string) { - return await this.connection.createQueryBuilder() - .delete() - .from('shares') - .where('doc_id = :docId and key = :apiKey', {docId, apiKey}) - .execute() || undefined; + return this._sharesManager.deleteDocApiKeyByKey(docId, apiKey); } - public async getDocApiKeys(docId: string): Promise { - return await this._connection.createQueryBuilder() - .select('shares') - .from(Share, 'shares') - .where('doc_id = :docId', {docId}) - .getMany() || undefined; + public async deleteDocApiKeyByLinkId(docId: string, linkId: string) { + return this._sharesManager.deleteDocApiKeyByLinkId(docId, linkId); } - public async deleteDocApiKeyByLinkId(docId: string, linkId: string) { - return await this.connection.createQueryBuilder() - .delete() - .from('shares') - .where('doc_id = :docId and link_id = :linkId', {docId, linkId}) - .execute() || undefined; + public async getDocApiKeys(docId: string): Promise { + return this._sharesManager.getDocApiKeys(docId); } public async deleteDocApiKeys(docId: string) { - return await this.connection.createQueryBuilder() - .delete() - .from('shares') - .where('doc_id = :docId', {docId}) - .execute() || undefined; + return this._sharesManager.deleteDocApiKeys(docId); } public getAnonymousUser() { @@ -2807,54 +2768,15 @@ export class HomeDBManager extends EventEmitter { } public async syncShares(docId: string, shares: ShareInfo[]) { - return this._connection.transaction(async manager => { - for (const share of shares) { - const key = makeId(); - await manager.createQueryBuilder() - .insert() - // if urlId has been used before, update it - .onConflict(`(doc_id, link_id) DO UPDATE SET options = :options`) - .setParameter('options', share.options) - .into(Share) - .values({ - linkId: share.linkId, - docId, - options: JSON.parse(share.options), - key, - }) - .execute(); - } - const dbShares = await manager.createQueryBuilder() - .select('shares') - .from(Share, 'shares') - .where('doc_id = :docId', {docId}) - .getMany(); - const activeLinkIds = new Set(shares.map(share => share.linkId)); - const oldShares = dbShares.filter(share => !activeLinkIds.has(share.linkId)); - if (oldShares.length > 0) { - await manager.createQueryBuilder() - .delete() - .from('shares') - .whereInIds(oldShares.map(share => share.id)) - .execute(); - } - }); + return this._sharesManager.syncShares(docId, shares); } public async getShareByKey(key: string) { - return this._connection.createQueryBuilder() - .select('shares') - .from(Share, 'shares') - .where('shares.key = :key', {key}) - .getOne(); + return this._sharesManager.getShareByKey(key); } public async getShareByLinkId(docId: string, linkId: string) { - return this._connection.createQueryBuilder() - .select('shares') - .from(Share, 'shares') - .where('shares.doc_id = :docId and shares.link_id = :linkId', {docId, linkId}) - .getOne(); + return this._sharesManager.getShareByLinkId(docId, linkId); } private async _getOrgMembers(org: string|number|Organization) { diff --git a/app/gen-server/lib/homedb/SharesManager.ts b/app/gen-server/lib/homedb/SharesManager.ts new file mode 100644 index 0000000000..b083bf942f --- /dev/null +++ b/app/gen-server/lib/homedb/SharesManager.ts @@ -0,0 +1,139 @@ +import {ShareInfo} from 'app/common/ActiveDocAPI'; +import {Share} from "app/gen-server/entity/Share"; +import {makeId} from 'app/server/lib/idUtils'; +import {HomeDBManager} from 'app/gen-server/lib/homedb/HomeDBManager'; + +export class SharesManager { + + private get _connection () { + return this._homeDb.connection; + } + + public constructor( + private readonly _homeDb: HomeDBManager, + ) {} + + public async syncShares(docId: string, shares: ShareInfo[]) { + return this._connection.transaction(async manager => { + for (const share of shares) { + const key = makeId(); + await manager.createQueryBuilder() + .insert() + // if urlId has been used before, update it + .onConflict(`(doc_id, link_id) DO UPDATE SET options = :options`) + .setParameter('options', share.options) + .into(Share) + .values({ + linkId: share.linkId, + docId, + options: JSON.parse(share.options), + key, + }) + .execute(); + } + const dbShares = await manager.createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .where('doc_id = :docId', {docId}) + .getMany(); + const activeLinkIds = new Set(shares.map(share => share.linkId)); + const oldShares = dbShares.filter(share => !activeLinkIds.has(share.linkId)); + if (oldShares.length > 0) { + await manager.createQueryBuilder() + .delete() + .from('shares') + .whereInIds(oldShares.map(share => share.id)) + .execute(); + } + }); + } + + public async getShareByKey(key: string) { + return this._connection.createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .where('shares.key = :key', {key}) + .getOne(); + } + + public async getShareByLinkId(docId: string, linkId: string) { + return this._connection.createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .where('shares.doc_id = :docId', {docId}) + .andWhere('shares.link_id = :linkId', {linkId}) + .getOne(); + } + + public getDocApiKeyByLinkId(docId: string, linkId: string): Promise { + return this.getShareByLinkId(docId, linkId); + } + + public getDocApiKeyByKey(key: string): Promise { + return this.getShareByKey(key); + } + + public async createDocApiKey(docId: string, share: ShareInfo) { + const key = makeId(); + const query = await this._connection.createQueryBuilder() + .insert() + .setParameter('options', share.options) + .into(Share) + .values({ + linkId: share.linkId, + docId: docId, + options: JSON.parse(share.options), + key, + }) + .execute() || undefined; + return query ? key : query; + } + + public async updateDocApiKeyByLinkId(docId: string, linkId: string, share: ShareInfo) { + return await this._connection.createQueryBuilder() + .update(Share) + .set(share) + .where('doc_id = :docId and link_id = :linkId', {docId, linkId}) + .execute() || undefined; + } + + public async updateDocApiKeyByKey(docId: string, apiKey: string, share: ShareInfo) { + return await this._connection.createQueryBuilder() + .update(Share) + .set(share) + .where('doc_id = :docId and key = :apiKey', {docId, apiKey}) + .execute() || undefined; + } + + public async deleteDocApiKeyByKey(docId: string, apiKey: string) { + return await this._connection.createQueryBuilder() + .delete() + .from('shares') + .where('doc_id = :docId and key = :apiKey', {docId, apiKey}) + .execute() || undefined; + } + + public async getDocApiKeys(docId: string): Promise { + return await this._connection.createQueryBuilder() + .select('shares') + .from(Share, 'shares') + .where('doc_id = :docId', {docId}) + .getMany() || undefined; + } + + public async deleteDocApiKeyByLinkId(docId: string, linkId: string) { + return await this._connection.createQueryBuilder() + .delete() + .from('shares') + .where('doc_id = :docId and link_id = :linkId', {docId, linkId}) + .execute() || undefined; + } + + public async deleteDocApiKeys(docId: string) { + return await this._connection.createQueryBuilder() + .delete() + .from('shares') + .where('doc_id = :docId', {docId}) + .execute() || undefined; + } +} diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 32c0b4d0bb..57b4df5238 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -210,12 +210,13 @@ export async function addRequestUser( const anon = dbManager.getAnonymousUser(); const org = await dbManager.getOrgOwnerFromDocApiKey(share); const userId = org?.ownerId; - const docAuth = await getOrSetDocAuth(mreq, dbManager, options.gristServer, did[0]); - const access = share.options.access ? share.options.access : null; mreq.user = anon; mreq.userId = userId; - mreq.userIsAuthorized = true; + + const docAuth = await getOrSetDocAuth(mreq, dbManager, options.gristServer, did[0]); + const access = share.options.access ? share.options.access : null; mreq.docAuth = {...docAuth, access}; + mreq.userIsAuthorized = true; hasApiKey = true; // If Bearer have another form it must be a User Api Key From 4cf4b2c5c92d9657e10dfa44c16b5fdbc00d5dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Tue, 23 Jul 2024 16:48:44 +0200 Subject: [PATCH 29/31] fix: broken ApiKeyAccess tests --- app/gen-server/lib/homedb/HomeDBManager.ts | 2 +- app/server/lib/Authorizer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/gen-server/lib/homedb/HomeDBManager.ts b/app/gen-server/lib/homedb/HomeDBManager.ts index 3c0bd5af14..b52cd181eb 100644 --- a/app/gen-server/lib/homedb/HomeDBManager.ts +++ b/app/gen-server/lib/homedb/HomeDBManager.ts @@ -977,7 +977,7 @@ export class HomeDBManager extends EventEmitter { .leftJoin(Workspace, 'workspaces', 'orgs.id = workspaces.org_id') .leftJoin(Document, 'docs', 'workspaces.id = docs.workspace_id') .leftJoin(Share, 'shares', 'docs.id = shares.doc_id') - .where('shares.key = :key', {key: 'uKXGYSc5pZ2muaQP9WKgpY'}) + .where('shares.key = :key', {key: docApiKey.key}) .getOne(); } diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 57b4df5238..b39aecb63f 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -178,7 +178,7 @@ export async function addRequestUser( // header needs to be of form "Bearer XXXXXXXXX" to apply if (parts[0] === "Bearer") { // Bearer needs to be form "DOC-YYYYYYYYY" to apply as Doc Api key - if (parts[1].match(/^DOC-.*/)) { + if (parts[1]?.match(/^DOC-.*/)) { const url = mreq.url; const didRegex = /(?<=^\/docs\/).+?(?=\/|$)/g; From 3ab5f95fb57ffe4a246dd80f567ca2912528d2ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Tue, 23 Jul 2024 16:51:05 +0200 Subject: [PATCH 30/31] Typo fix --- test/gen-server/ApiServer.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/gen-server/ApiServer.ts b/test/gen-server/ApiServer.ts index d7184629bb..ad7021f619 100644 --- a/test/gen-server/ApiServer.ts +++ b/test/gen-server/ApiServer.ts @@ -2854,9 +2854,11 @@ describe('ApiServer', function() { // Make a call to GET /api/doc/:docId const authorizationHeader = `Bearer DOC-${docApiKey}`; - const anonymous = {...nobody, Authorization : authorizationHeader}; + const anonymous = {...nobody}; + anonymous.headers!.Authorization = authorizationHeader; + const fetch = await axios.get(`${homeUrl}/api/docs/${did}`, anonymous); - assert.equal(fetch.status, 200); + assert.equal(fetch.status, 200, "DocApiKey valid as Apikey"); }); it('must fail when doApiKey is wrong', async function() { From ea05508ba70626dfa096a5752feafeb5ead0ff5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Cutzach?= Date: Wed, 7 Aug 2024 17:32:25 +0200 Subject: [PATCH 31/31] feat: REMOVE unused code --- app/client/ui/DocApiKey.ts | 145 ------------------------------------- 1 file changed, 145 deletions(-) delete mode 100644 app/client/ui/DocApiKey.ts diff --git a/app/client/ui/DocApiKey.ts b/app/client/ui/DocApiKey.ts deleted file mode 100644 index 9d8755f0bb..0000000000 --- a/app/client/ui/DocApiKey.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { makeT } from 'app/client/lib/localization'; -import { basicButton, textButton } from 'app/client/ui2018/buttons'; -import { theme, vars } from 'app/client/ui2018/cssVars'; -import { icon } from 'app/client/ui2018/icons'; -import { confirmModal } from 'app/client/ui2018/modals'; -import { Disposable, dom, IDomArgs, makeTestId, Observable, observable, styled } from 'grainjs'; - -const t = makeT('DocApiKey'); - -interface IWidgetOptions { - docApiKey: Observable; - onDelete: () => Promise; - onCreate: () => Promise; - inputArgs?: IDomArgs; -} - -const testId = makeTestId('test-docapikey-'); - -/** - * DocApiKey component shows an api key with controls to change it. Expects `options.docApiKey` the api - * key and shows it if value is truthy along with a 'Delete' button that triggers the - * `options.onDelete` callback. When `options.docApiKey` is falsy, hides it and show a 'Create' button - * that triggers the `options.onCreate` callback. It is the responsibility of the caller to update - * the `options.docApiKey` to its new value. - */ -export class DocApiKey extends Disposable { - // TODO : user actually logged in, and value if the user is owner of the document. - private _docApiKey: Observable; - private _onDeleteCB: () => Promise; - private _onCreateCB: () => Promise; - private _inputArgs: IDomArgs; - private _loading = observable(false); - private _isHidden: Observable = Observable.create(this, true); - - constructor(options: IWidgetOptions) { - super(); - this._docApiKey = options.docApiKey; - this._onDeleteCB = options.onDelete; - this._onCreateCB = options.onCreate; - this._inputArgs = options.inputArgs ?? []; - } - - public buildDom() { - return dom('div', testId('container'), dom.style('position', 'relative'), - dom.maybe(this._docApiKey, (docApiKey) => dom('div', - cssRow( - cssInput( - { - readonly: true, - value: this._docApiKey.get(), - }, - dom.attr('type', (use) => use(this._isHidden) ? 'password' : 'text'), - testId('key'), - {title: t("Click to show")}, - dom.on('click', (_ev, el) => { - this._isHidden.set(false); - setTimeout(() => el.select(), 0); - }), - dom.on('blur', (ev) => { - // Hide the key when it is no longer selected. - if (ev.target !== document.activeElement) { this._isHidden.set(true); } - }), - this._inputArgs - ), - cssTextBtn( - cssTextBtnIcon('Remove'), t("Remove"), - dom.on('click', () => this._showRemoveKeyModal()), - testId('delete'), - dom.boolAttr('disabled', (use) => use(this._loading)) // or is not owner - ), - ), - description('This doc API key can be used to access this document via the API. \ -Don’t share this API key.', testId('description')), - )), - dom.maybe((use) => !use(this._docApiKey), () => [ - basicButton(t("Create"), dom.on('click', () => this._onCreate()), testId('create'), - dom.boolAttr('disabled', this._loading)), - description(t("By generating a doc API key, you will be able to \ -make API calls for this particular document."), testId('description')), - ]), - ); - } - - // Switch the `_loading` flag to `true` and later, once promise resolves, switch it back to - // `false`. - private async _switchLoadingFlag(promise: Promise) { - this._loading.set(true); - try { - await promise; - } finally { - this._loading.set(false); - } - } - - private _onDelete(): Promise { - return this._switchLoadingFlag(this._onDeleteCB()); - } - - private _onCreate(): Promise { - return this._switchLoadingFlag(this._onCreateCB()); - } - - private _showRemoveKeyModal(): void { - confirmModal( - t("Remove API Key"), t("Remove"), - () => this._onDelete(), - { - explanation: t( - "You're about to delete a doc API key. This will cause all future requests \ -using this doc API key to be rejected. Do you still want to delete?" - ), - } - ); - } -} - -const description = styled('div', ` - margin-top: 8px; - color: ${theme.lightText}; - font-size: ${vars.mediumFontSize}; -`); - -const cssInput = styled('input', ` - background-color: transparent; - color: ${theme.inputFg}; - border: 1px solid ${theme.inputBorder}; - padding: 4px; - border-radius: 3px; - outline: none; - flex: 1 0 0; -`); - -const cssRow = styled('div', ` - display: flex; -`); - -const cssTextBtn = styled(textButton, ` - text-align: left; - width: 90px; - margin-left: 16px; -`); - -const cssTextBtnIcon = styled(icon, ` - margin: 0 4px 2px 0; -`);