diff --git a/app/gen-server/ApiServer.ts b/app/gen-server/ApiServer.ts index 6f935431..13e45ea1 100644 --- a/app/gen-server/ApiServer.ts +++ b/app/gen-server/ApiServer.ts @@ -492,7 +492,7 @@ export class ApiServer { // Sets active user for active org this._app.post('/api/session/access/active', expressWrap(async (req, res) => { const mreq = req as RequestWithLogin; - let domain = optStringParam(req.body.org); + let domain = optStringParam(req.body.org, 'org'); if (!domain || domain === 'current') { domain = getOrgFromRequest(mreq) || ''; } diff --git a/app/gen-server/lib/HomeDBManager.ts b/app/gen-server/lib/HomeDBManager.ts index b86c5ce0..5d0f1e03 100644 --- a/app/gen-server/lib/HomeDBManager.ts +++ b/app/gen-server/lib/HomeDBManager.ts @@ -1807,6 +1807,9 @@ export class HomeDBManager extends EventEmitter { return queryResult; } const workspace: Workspace = queryResult.data; + if (workspace.removedAt) { + throw new ApiError('Cannot add document to a deleted workspace', 400); + } await this._checkRoomForAnotherDoc(workspace, manager); // Create a new document. const doc = new Document(); diff --git a/app/gen-server/lib/Housekeeper.ts b/app/gen-server/lib/Housekeeper.ts index 1dac3cc1..8534c5a1 100644 --- a/app/gen-server/lib/Housekeeper.ts +++ b/app/gen-server/lib/Housekeeper.ts @@ -260,7 +260,7 @@ export class Housekeeper { // which worker group the document is assigned a worker from. app.post('/api/housekeeping/docs/:docId/assign', this._withSupport(async (req, docId, headers) => { const url = new URL(await this._server.getHomeUrlByDocId(docId, `/api/docs/${docId}/assign`)); - const group = optStringParam(req.query.group); + const group = optStringParam(req.query.group, 'group'); if (group !== undefined) { url.searchParams.set('group', group); } return fetch(url.toString(), { method: 'POST', diff --git a/app/server/lib/Authorizer.ts b/app/server/lib/Authorizer.ts index 33cb657c..033a56d5 100644 --- a/app/server/lib/Authorizer.ts +++ b/app/server/lib/Authorizer.ts @@ -165,7 +165,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer // Support providing an access token via an `auth` query parameter. // This is useful for letting the browser load assets like image // attachments. - const auth = optStringParam(mreq.query.auth); + const auth = optStringParam(mreq.query.auth, 'auth'); if (auth) { const tokens = options.gristServer.getAccessTokens(); const token = await tokens.verify(auth); @@ -310,7 +310,8 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer // See if we have a profile linked with the active organization already. // TODO: implement userSelector for rest API, to allow "sticky" user selection on pages. - let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, optStringParam(mreq.query.user) || ''); + let sessionUser: SessionUserObj|null = getSessionUser(session, mreq.org, + optStringParam(mreq.query.user, 'user') || ''); if (!sessionUser) { // No profile linked yet, so let's elect one. diff --git a/app/server/lib/DocApi.ts b/app/server/lib/DocApi.ts index e0824acb..1b707d7c 100644 --- a/app/server/lib/DocApi.ts +++ b/app/server/lib/DocApi.ts @@ -440,9 +440,9 @@ export class DocWorkerApi { // Responds with attachment contents, with suitable Content-Type and Content-Disposition. this._app.get('/api/docs/:docId/attachments/:attId/download', canView, withDoc(async (activeDoc, req, res) => { const attId = integerParam(req.params.attId, 'attId'); - const tableId = optStringParam(req.params.tableId); - const colId = optStringParam(req.params.colId); - const rowId = optIntegerParam(req.params.rowId); + const tableId = optStringParam(req.params.tableId, 'tableId'); + const colId = optStringParam(req.params.colId, 'colId'); + const rowId = optIntegerParam(req.params.rowId, 'rowId'); if ((tableId || colId || rowId) && !(tableId && colId && rowId)) { throw new ApiError('define all of tableId, colId and rowId, or none.', 400); } @@ -720,8 +720,9 @@ export class DocWorkerApi { const options = { add: !isAffirmative(req.query.noadd), update: !isAffirmative(req.query.noupdate), - onMany: stringParam(req.query.onmany || "first", "onmany", - ["first", "none", "all"]) as 'first'|'none'|'all'|undefined, + onMany: stringParam(req.query.onmany || "first", "onmany", { + allowed: ["first", "none", "all"], + }) as 'first'|'none'|'all'|undefined, allowEmptyRequire: isAffirmative(req.query.allow_empty_require), }; await ops.upsert(body.records, options); @@ -982,7 +983,7 @@ export class DocWorkerApi { // (Requires a special permit.) this._app.post('/api/docs/:docId/assign', canEdit, throttled(async (req, res) => { const docId = getDocId(req); - const group = optStringParam(req.query.group); + const group = optStringParam(req.query.group, 'group'); if (group !== undefined && req.specialPermit?.action === 'assign-doc') { if (group.trim() === '') { await this._docWorkerMap.removeDocGroup(docId); @@ -1148,7 +1149,12 @@ export class DocWorkerApi { const userId = getUserId(req); const wsId = integerParam(req.params.wid, 'wid'); const uploadId = integerParam(req.body.uploadId, 'uploadId'); - const result = await this._docManager.importDocToWorkspace(userId, uploadId, wsId, req.body.browserSettings); + const result = await this._docManager.importDocToWorkspace({ + userId, + uploadId, + workspaceId: wsId, + browserSettings: req.body.browserSettings, + }); res.json(result); })); @@ -1215,16 +1221,22 @@ export class DocWorkerApi { }) ); - // Create a document. When an upload is included, it is imported as the initial - // state of the document. Otherwise a fresh empty document is created. - // A "timezone" option can be supplied. - // Documents are created "unsaved". - // TODO: support workspaceId option for creating regular documents, at which point - // existing import endpoint and doc creation endpoint can share implementation - // with this. - // Returns the id of the created document. + /** + * Create a document. + * + * When an upload is included, it is imported as the initial state of the document. + * Otherwise, the document is left empty. + * + * If a workspace id is included, the document will be saved there instead of + * being left "unsaved". + * + * Returns the id of the created document. + * + * TODO: unify this with the other document creation and import endpoints. + */ this._app.post('/api/docs', expressWrap(async (req, res) => { const userId = getUserId(req); + let uploadId: number|undefined; let parameters: {[key: string]: any}; if (req.is('multipart/form-data')) { @@ -1236,22 +1248,52 @@ export class DocWorkerApi { } else { parameters = req.body; } - if (parameters.workspaceId) { throw new Error('workspaceId not supported'); } + + const documentName = optStringParam(parameters.documentName, 'documentName', { + allowEmpty: false, + }); + const workspaceId = optIntegerParam(parameters.workspaceId, 'workspaceId'); const browserSettings: BrowserSettings = {}; if (parameters.timezone) { browserSettings.timezone = parameters.timezone; } browserSettings.locale = localeFromRequest(req); + + let docId: string; if (uploadId !== undefined) { - const result = await this._docManager.importDocToWorkspace(userId, uploadId, null, - browserSettings); - return res.json(result.id); + const result = await this._docManager.importDocToWorkspace({ + userId, + uploadId, + documentName, + workspaceId, + browserSettings, + }); + docId = result.id; + } else if (workspaceId !== undefined) { + const {status, data, errMessage} = await this._dbManager.addDocument(getScope(req), workspaceId, { + name: documentName ?? 'Untitled document', + }); + if (status !== 200) { + throw new ApiError(errMessage || 'unable to create document', status); + } + + docId = data!; + } else { + const isAnonymous = isAnonymousUser(req); + const result = makeForkIds({ + userId, + isAnonymous, + trunkDocId: NEW_DOCUMENT_CODE, + trunkUrlId: NEW_DOCUMENT_CODE, + }); + docId = result.docId; + await this._docManager.createNamedDoc( + makeExceptionalDocSession('nascent', { + req: req as RequestWithLogin, + browserSettings, + }), + docId + ); } - const isAnonymous = isAnonymousUser(req); - const {docId} = makeForkIds({userId, isAnonymous, trunkDocId: NEW_DOCUMENT_CODE, - trunkUrlId: NEW_DOCUMENT_CODE}); - await this._docManager.createNamedDoc(makeExceptionalDocSession('nascent', { - req: req as RequestWithLogin, - browserSettings - }), docId); + return res.status(200).json(docId); })); } @@ -1556,7 +1598,7 @@ export class DocWorkerApi { } const timeout = Math.max(0, Math.min(MAX_CUSTOM_SQL_MSEC, - optIntegerParam(options.timeout) || MAX_CUSTOM_SQL_MSEC)); + optIntegerParam(options.timeout, 'timeout') || MAX_CUSTOM_SQL_MSEC)); // Wrap in a select to commit to the SELECT branch of SQLite // grammar. Note ; isn't a problem. // @@ -1639,7 +1681,7 @@ export interface QueryParameters { * as a header. */ function getSortParameter(req: Request): string[]|undefined { - const sortString: string|undefined = optStringParam(req.query.sort) || req.get('X-Sort'); + const sortString: string|undefined = optStringParam(req.query.sort, 'sort') || req.get('X-Sort'); if (!sortString) { return undefined; } return sortString.split(','); } @@ -1650,7 +1692,7 @@ function getSortParameter(req: Request): string[]|undefined { * parameter, or as a header. */ function getLimitParameter(req: Request): number|undefined { - const limitString: string|undefined = optStringParam(req.query.limit) || req.get('X-Limit'); + const limitString: string|undefined = optStringParam(req.query.limit, 'limit') || req.get('X-Limit'); if (!limitString) { return undefined; } const limit = parseInt(limitString, 10); if (isNaN(limit)) { throw new Error('limit is not a number'); } diff --git a/app/server/lib/DocManager.ts b/app/server/lib/DocManager.ts index 800775d4..c2b5768f 100644 --- a/app/server/lib/DocManager.ts +++ b/app/server/lib/DocManager.ts @@ -178,21 +178,36 @@ export class DocManager extends EventEmitter { {naming: 'saved'}); } - // Do an import targeted at a specific workspace. Cleans up uploadId. - // UserId should correspond to the user making the request. - // A workspaceId of null results in an import to an unsaved doc, not - // associated with a specific workspace. - public async importDocToWorkspace( - userId: number, uploadId: number, workspaceId: number|null, browserSettings?: BrowserSettings, - ): Promise { + /** + * Do an import targeted at a specific workspace. + * + * `userId` should correspond to the user making the request. + * + * If workspaceId is omitted, an unsaved doc unassociated with a specific workspace + * will be created. + * + * Cleans up `uploadId` and returns creation info about the imported doc. + */ + public async importDocToWorkspace(options: { + userId: number, + uploadId: number, + documentName?: string, + workspaceId?: number, + browserSettings?: BrowserSettings, + }): Promise { if (!this._homeDbManager) { throw new Error("HomeDbManager not available"); } + const {userId, uploadId, documentName, workspaceId, browserSettings} = options; const accessId = this.makeAccessId(userId); const docSession = makeExceptionalDocSession('nascent', {browserSettings}); - const register = async (docId: string, docTitle: string) => { - if (!workspaceId || !this._homeDbManager) { return; } - const queryResult = await this._homeDbManager.addDocument({userId}, workspaceId, - {name: docTitle}, docId); + const register = async (docId: string, uploadBaseFilename: string) => { + if (workspaceId === undefined || !this._homeDbManager) { return; } + const queryResult = await this._homeDbManager.addDocument( + {userId}, + workspaceId, + {name: documentName ?? uploadBaseFilename}, + docId + ); if (queryResult.status !== 200) { // TODO The ready-to-add document is not yet in storageManager, but is in the filesystem. It // should get cleaned up in case of error here. @@ -555,7 +570,7 @@ export class DocManager extends EventEmitter { private async _doImportDoc(docSession: OptDocSession, uploadInfo: UploadInfo, options: { naming: 'classic'|'saved'|'unsaved', - register?: (docId: string, docTitle: string) => Promise, + register?: (docId: string, uploadBaseFilename: string) => Promise, userId?: number, }): Promise { try { diff --git a/app/server/lib/DocWorker.ts b/app/server/lib/DocWorker.ts index 1e5eb084..3c0de23c 100644 --- a/app/server/lib/DocWorker.ts +++ b/app/server/lib/DocWorker.ts @@ -88,16 +88,20 @@ export class DocWorker { await filterDocumentInPlace(docSessionFromRequest(mreq), tmpPath); // NOTE: We may want to reconsider the mimeType used for Grist files. return res.type('application/x-sqlite3') - .download(tmpPath, (optStringParam(req.query.title) || docTitle || 'document') + ".grist", async (err: any) => { - if (err) { - if (err.message && /Request aborted/.test(err.message)) { - log.warn(`Download request aborted for doc ${docId}`, err); - } else { - log.error(`Download failure for doc ${docId}`, err); + .download( + tmpPath, + (optStringParam(req.query.title, 'title') || docTitle || 'document') + ".grist", + async (err: any) => { + if (err) { + if (err.message && /Request aborted/.test(err.message)) { + log.warn(`Download request aborted for doc ${docId}`, err); + } else { + log.error(`Download failure for doc ${docId}`, err); + } } + await fse.unlink(tmpPath); } - await fse.unlink(tmpPath); - }); + ); } // Register main methods related to documents. @@ -156,7 +160,7 @@ export class DocWorker { const mreq = req as RequestWithLogin; let urlId: string|undefined; try { - if (optStringParam(req.query.clientId)) { + if (optStringParam(req.query.clientId, 'clientId')) { const activeDoc = this._getDocSession(stringParam(req.query.clientId, 'clientId'), integerParam(req.query.docFD, 'docFD')).activeDoc; // TODO: The docId should be stored in the ActiveDoc class. Currently docName is diff --git a/app/server/lib/Export.ts b/app/server/lib/Export.ts index cf49aef8..0d590f20 100644 --- a/app/server/lib/Export.ts +++ b/app/server/lib/Export.ts @@ -113,7 +113,7 @@ export interface DownloadOptions extends ExportParameters { */ export function parseExportParameters(req: express.Request): ExportParameters { const tableId = stringParam(req.query.tableId, 'tableId'); - const viewSectionId = optIntegerParam(req.query.viewSection); + const viewSectionId = optIntegerParam(req.query.viewSection, 'viewSection'); const sortOrder = optJsonParam(req.query.activeSortSpec, []) as number[]; const filters: Filter[] = optJsonParam(req.query.filters, []); const linkingFilter: FilterColValues = optJsonParam(req.query.linkingFilter, null); diff --git a/app/server/lib/FlexServer.ts b/app/server/lib/FlexServer.ts index 1aa73ea2..cb60b1d2 100644 --- a/app/server/lib/FlexServer.ts +++ b/app/server/lib/FlexServer.ts @@ -436,7 +436,7 @@ export class FlexServer implements GristServer { public testAddRouter() { if (this._check('router')) { return; } this.app.get('/test/router', (req, res) => { - const act = optStringParam(req.query.act) || 'none'; + const act = optStringParam(req.query.act, 'act') || 'none'; const port = stringParam(req.query.port, 'port'); // port is trusted in mock; in prod it is not. if (act === 'add' || act === 'remove') { const host = `localhost:${port}`; @@ -1036,12 +1036,12 @@ export class FlexServer implements GristServer { log.warn("Serving unauthenticated /test/login endpoint, made available because GRIST_TEST_LOGIN is set."); // Query parameter is called "username" for compatibility with Cognito. - const email = optStringParam(req.query.username); + const email = optStringParam(req.query.username, 'username'); if (email) { - const redirect = optStringParam(req.query.next); + const redirect = optStringParam(req.query.next, 'next'); const profile: UserProfile = { email, - name: optStringParam(req.query.name) || email, + name: optStringParam(req.query.name, 'name') || email, }; const url = new URL(redirect || getOrgUrl(req)); // Make sure we update session for org we'll be redirecting to. @@ -1884,7 +1884,7 @@ export class FlexServer implements GristServer { forceSessionChange(mreq.session); // Redirect to the requested URL after successful login. if (!nextUrl) { - const nextPath = optStringParam(req.query.next); + const nextPath = optStringParam(req.query.next, 'next'); nextUrl = new URL(getOrgUrl(req, nextPath)); } if (signUp === undefined) { diff --git a/app/server/lib/ForwardAuthLogin.ts b/app/server/lib/ForwardAuthLogin.ts index a61d5ce1..d0912126 100644 --- a/app/server/lib/ForwardAuthLogin.ts +++ b/app/server/lib/ForwardAuthLogin.ts @@ -71,7 +71,7 @@ export async function getForwardAuthLoginSystem(): Promise { const mreq = req as RequestWithLogin; - const event = stringParam(req.body.event, 'event', TelemetryEvents.values) as TelemetryEvent; + const event = stringParam(req.body.event, 'event', {allowed: TelemetryEvents.values}) as TelemetryEvent; if ('eventSource' in req.body.metadata) { this._telemetryLogger.rawLog('info', getEventType(event), event, { ...(removeNullishKeys(req.body.metadata)), diff --git a/app/server/lib/requestUtils.ts b/app/server/lib/requestUtils.ts index 96a29234..3bf6d88e 100644 --- a/app/server/lib/requestUtils.ts +++ b/app/server/lib/requestUtils.ts @@ -256,23 +256,40 @@ export function getDocId(req: Request) { return mreq.docAuth.docId; } -export function optStringParam(p: any): string|undefined { - if (typeof p === 'string') { return p; } - return undefined; +export interface StringParamOptions { + allowed?: readonly string[]; + /* Defaults to true. */ + allowEmpty?: boolean; } -export function stringParam(p: any, name: string, allowed?: readonly string[]): string { +export function optStringParam(p: any, name: string, options: StringParamOptions = {}): string|undefined { + if (p === undefined) { return p; } + + return stringParam(p, name, options); +} + +export function stringParam(p: any, name: string, options: StringParamOptions = {}): string { + const {allowed, allowEmpty = true} = options; if (typeof p !== 'string') { throw new ApiError(`${name} parameter should be a string: ${p}`, 400); } + if (!allowEmpty && p === '') { + throw new ApiError(`${name} parameter cannot be empty`, 400); + } if (allowed && !allowed.includes(p)) { throw new ApiError(`${name} parameter ${p} should be one of ${allowed}`, 400); } return p; } +export function optIntegerParam(p: any, name: string): number|undefined { + if (p === undefined) { return p; } + + return integerParam(p, name); +} + export function integerParam(p: any, name: string): number { - if (typeof p === 'number') { return Math.floor(p); } + if (typeof p === 'number' && !Number.isNaN(p)) { return Math.floor(p); } if (typeof p === 'string') { const result = parseInt(p, 10); if (isNaN(result)) { @@ -283,12 +300,6 @@ export function integerParam(p: any, name: string): number { throw new ApiError(`${name} parameter should be an integer: ${p}`, 400); } -export function optIntegerParam(p: any): number|undefined { - if (typeof p === 'number') { return Math.floor(p); } - if (typeof p === 'string') { return parseInt(p, 10); } - return undefined; -} - export function optJsonParam(p: any, defaultValue: any): any { if (typeof p !== 'string') { return defaultValue; } return gutil.safeJsonParse(p, defaultValue); diff --git a/app/server/lib/uploads.ts b/app/server/lib/uploads.ts index 29575c17..e67a083b 100644 --- a/app/server/lib/uploads.ts +++ b/app/server/lib/uploads.ts @@ -67,8 +67,8 @@ export function addUploadRoute(server: GristServer, expressApp: Application, ... // Like upload, but copy data from a document already known to us. expressApp.post(`/copy`, ...handlers, expressWrap(async (req: Request, res: Response) => { - const docId = optStringParam(req.query.doc); - const name = optStringParam(req.query.name); + const docId = optStringParam(req.query.doc, 'doc'); + const name = optStringParam(req.query.name, 'name'); if (!docId) { throw new Error('doc must be specified'); } const accessId = makeAccessId(req, getAuthorizedUserId(req)); try { diff --git a/test/server/lib/DocApi.ts b/test/server/lib/DocApi.ts index 036bed68..f6e0ad37 100644 --- a/test/server/lib/DocApi.ts +++ b/test/server/lib/DocApi.ts @@ -333,9 +333,9 @@ function testDocApi() { ); }); - for (const mode of ['logged in', 'anonymous']) { - for (const content of ['with content', 'without content']) { - it(`POST /api/docs ${content} creates an unsaved doc when ${mode}`, async function () { + for (const content of ['with content', 'without content']) { + for (const mode of ['logged in', 'anonymous']) { + it(`POST /api/docs ${content} can create unsaved docs when ${mode}`, async function () { const user = (mode === 'logged in') ? chimpy : nobody; const formData = new FormData(); formData.append('upload', 'A,B\n1,2\n3,4\n', 'table1.csv'); @@ -375,6 +375,157 @@ function testDocApi() { } }); } + + it(`POST /api/docs ${content} can create saved docs in workspaces`, async function () { + // Make a workspace. + const chimpyWs = await userApi.newWorkspace({name: "Chimpy's Workspace"}, ORG_NAME); + + // Create a document in the new workspace. + const user = chimpy; + const body = { + documentName: "Chimpy's Document", + workspaceId: chimpyWs, + }; + const formData = new FormData(); + formData.append('upload', 'A,B\n1,2\n3,4\n', 'table1.csv'); + formData.append('documentName', body.documentName); + formData.append('workspaceId', body.workspaceId); + const config = defaultsDeep({headers: formData.getHeaders()}, user); + let resp = await axios.post(`${serverUrl}/api/docs`, + ...(content === 'with content' + ? [formData, config] + : [body, user]) + ); + assert.equal(resp.status, 200); + const urlId = resp.data; + assert.notMatch(urlId, /^new~[^~]*~[0-9]+$/); + assert.match(urlId, /^[^~]+$/); + + // Check document metadata. + resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, user); + assert.equal(resp.status, 200); + assert.equal(resp.data.name, "Chimpy's Document"); + assert.equal(resp.data.workspace.name, "Chimpy's Workspace"); + assert.equal(resp.data.access, 'owners'); + resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, charon); + assert.equal(resp.status, 200); + resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, nobody); + assert.equal(resp.status, 403); + + // Check document contents. + resp = await axios.get(`${serverUrl}/api/docs/${urlId}/tables/Table1/data`, user); + if (content === 'with content') { + assert.deepEqual(resp.data, {id: [1, 2], manualSort: [1, 2], A: [1, 3], B: [2, 4]}); + } else { + assert.deepEqual(resp.data, {id: [], manualSort: [], A: [], B: [], C: []}); + } + + // Delete the workspace. + await userApi.deleteWorkspace(chimpyWs); + }); + + it(`POST /api/docs ${content} fails if workspace access is denied`, async function () { + // Make a workspace. + const chimpyWs = await userApi.newWorkspace({name: "Chimpy's Workspace"}, ORG_NAME); + + // Try to create a document in the new workspace as Kiwi and Charon, who do not have write access. + for (const user of [kiwi, charon]) { + const body = { + documentName: "Untitled document", + workspaceId: chimpyWs, + }; + const formData = new FormData(); + formData.append('upload', 'A,B\n1,2\n3,4\n', 'table1.csv'); + formData.append('documentName', body.documentName); + formData.append('workspaceId', body.workspaceId); + const config = defaultsDeep({headers: formData.getHeaders()}, user); + const resp = await axios.post(`${serverUrl}/api/docs`, + ...(content === 'with content' + ? [formData, config] + : [body, user]) + ); + assert.equal(resp.status, 403); + assert.equal(resp.data.error, 'access denied'); + } + + // Try to create a document in the new workspace as Chimpy, who does have write access. + const user = chimpy; + const body = { + documentName: "Chimpy's Document", + workspaceId: chimpyWs, + }; + const formData = new FormData(); + formData.append('upload', 'A,B\n1,2\n3,4\n', 'table1.csv'); + formData.append('documentName', body.documentName); + formData.append('workspaceId', body.workspaceId); + const config = defaultsDeep({headers: formData.getHeaders()}, user); + let resp = await axios.post(`${serverUrl}/api/docs`, + ...(content === 'with content' + ? [formData, config] + : [body, user]) + ); + assert.equal(resp.status, 200); + const urlId = resp.data; + assert.notMatch(urlId, /^new~[^~]*~[0-9]+$/); + assert.match(urlId, /^[^~]+$/); + resp = await axios.get(`${homeUrl}/api/docs/${urlId}`, user); + assert.equal(resp.status, 200); + assert.equal(resp.data.name, "Chimpy's Document"); + assert.equal(resp.data.workspace.name, "Chimpy's Workspace"); + assert.equal(resp.data.access, 'owners'); + + // Delete the workspace. + await userApi.deleteWorkspace(chimpyWs); + }); + + it(`POST /api/docs ${content} fails if workspace is soft-deleted`, async function () { + // Make a workspace and promptly remove it. + const chimpyWs = await userApi.newWorkspace({name: "Chimpy's Workspace"}, ORG_NAME); + await userApi.softDeleteWorkspace(chimpyWs); + + // Try to create a document in the soft-deleted workspace. + const user = chimpy; + const body = { + documentName: "Chimpy's Document", + workspaceId: chimpyWs, + }; + const formData = new FormData(); + formData.append('upload', 'A,B\n1,2\n3,4\n', 'table1.csv'); + formData.append('documentName', body.documentName); + formData.append('workspaceId', body.workspaceId); + const config = defaultsDeep({headers: formData.getHeaders()}, user); + const resp = await axios.post(`${serverUrl}/api/docs`, + ...(content === 'with content' + ? [formData, config] + : [body, user]) + ); + assert.equal(resp.status, 400); + assert.equal(resp.data.error, 'Cannot add document to a deleted workspace'); + + // Delete the workspace. + await userApi.deleteWorkspace(chimpyWs); + }); + + it(`POST /api/docs ${content} fails if workspace does not exist`, async function () { + // Try to create a document in a non-existent workspace. + const user = chimpy; + const body = { + documentName: "Chimpy's Document", + workspaceId: 123456789, + }; + const formData = new FormData(); + formData.append('upload', 'A,B\n1,2\n3,4\n', 'table1.csv'); + formData.append('documentName', body.documentName); + formData.append('workspaceId', body.workspaceId); + const config = defaultsDeep({headers: formData.getHeaders()}, user); + const resp = await axios.post(`${serverUrl}/api/docs`, + ...(content === 'with content' + ? [formData, config] + : [body, user]) + ); + assert.equal(resp.status, 404); + assert.equal(resp.data.error, 'workspace not found'); + }); } it("GET /docs/{did}/tables/{tid}/data retrieves data in column format", async function () {