diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 54171309de..c40041c7d6 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -1,18 +1,15 @@ {#if fieldState} option.primaryDisplay} getOptionValue={option => option._id} + {options} {placeholder} + {autocomplete} bind:searchTerm - loading={$fetch.loading} bind:open + on:change={handleChange} + on:loadMore={() => fetch?.nextPage()} /> {/if} diff --git a/packages/frontend-core/src/fetch/GroupUserFetch.ts b/packages/frontend-core/src/fetch/GroupUserFetch.ts index 5b07e9decb..ca28a4dfa1 100644 --- a/packages/frontend-core/src/fetch/GroupUserFetch.ts +++ b/packages/frontend-core/src/fetch/GroupUserFetch.ts @@ -4,7 +4,7 @@ import { GroupUserDatasource, InternalTable } from "@budibase/types" interface GroupUserQuery { groupId: string - emailSearch: string + emailSearch?: string } interface GroupUserDefinition { diff --git a/packages/frontend-core/src/fetch/UserFetch.ts b/packages/frontend-core/src/fetch/UserFetch.ts index e72ed8997d..995071b587 100644 --- a/packages/frontend-core/src/fetch/UserFetch.ts +++ b/packages/frontend-core/src/fetch/UserFetch.ts @@ -9,8 +9,8 @@ import { } from "@budibase/types" interface UserFetchQuery { - appId: string - paginated: boolean + appId?: string + paginated?: boolean } interface UserDefinition { diff --git a/packages/server/package.json b/packages/server/package.json index 9a70ecba9c..38dd746c4f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -156,6 +156,7 @@ "@types/pouchdb": "6.4.2", "@types/server-destroy": "1.0.1", "@types/supertest": "2.0.14", + "@types/swagger-jsdoc": "^6.0.4", "@types/tar": "6.1.5", "@types/tmp": "0.2.6", "@types/uuid": "8.3.4", diff --git a/packages/server/specs/generate.ts b/packages/server/specs/generate.ts index 8f6376195f..1176f187a7 100644 --- a/packages/server/specs/generate.ts +++ b/packages/server/specs/generate.ts @@ -4,11 +4,11 @@ import { examples, schemas } from "./resources" import * as parameters from "./parameters" import * as security from "./security" -const swaggerJsdoc = require("swagger-jsdoc") +import swaggerJsdoc from "swagger-jsdoc" const VARIABLES = {} -const options = { +const opts: swaggerJsdoc.Options = { definition: { openapi: "3.0.0", info: { @@ -58,30 +58,27 @@ const options = { } function writeFile(output: any, filename: string) { - try { - const path = join(__dirname, filename) - let spec = output - if (filename.endsWith("json")) { - spec = JSON.stringify(output, null, 2) - } - // input the static variables - for (let [key, replacement] of Object.entries(VARIABLES)) { - spec = spec.replace(new RegExp(`{${key}}`, "g"), replacement) - } - writeFileSync(path, spec) - console.log(`Wrote spec to ${path}`) - return path - } catch (err) { - console.error("Error writing spec file", err) + const path = join(__dirname, filename) + let spec = output + if (filename.endsWith("json")) { + spec = JSON.stringify(output, null, 2) } + // input the static variables + for (let [key, replacement] of Object.entries(VARIABLES)) { + spec = spec.replace(new RegExp(`{${key}}`, "g"), replacement) + } + writeFileSync(path, spec) + console.log(`Wrote spec to ${path}`) + return path +} + +export function spec() { + return swaggerJsdoc({ ...opts, format: ".json" }) } export function run() { - const outputJSON = swaggerJsdoc(options) - options.format = ".yaml" - const outputYAML = swaggerJsdoc(options) - writeFile(outputJSON, "openapi.json") - return writeFile(outputYAML, "openapi.yaml") + writeFile(swaggerJsdoc({ ...opts, format: ".json" }), "openapi.json") + return writeFile(swaggerJsdoc({ ...opts, format: ".yaml" }), "openapi.yaml") } if (require.main === module) { diff --git a/packages/server/specs/resources/error.ts b/packages/server/specs/resources/error.ts new file mode 100644 index 0000000000..0d42963b73 --- /dev/null +++ b/packages/server/specs/resources/error.ts @@ -0,0 +1,21 @@ +import { object } from "./utils" +import Resource from "./utils/Resource" + +const errorSchema = object({ + status: { + type: "number", + description: "The HTTP status code of the error.", + }, + message: { + type: "string", + description: "A descriptive message about the error.", + }, +}) + +export default new Resource() + .setExamples({ + error: {}, + }) + .setSchemas({ + error: errorSchema, + }) diff --git a/packages/server/src/api/controllers/public/users.ts b/packages/server/src/api/controllers/public/users.ts index 4265c7ac22..c0cd3248a2 100644 --- a/packages/server/src/api/controllers/public/users.ts +++ b/packages/server/src/api/controllers/public/users.ts @@ -48,7 +48,7 @@ function getUser(ctx: UserCtx, userId?: string) { if (userId) { ctx.params = { userId } } else if (!ctx.params?.userId) { - throw "No user ID provided for getting" + throw new Error("No user ID provided for getting") } return readGlobalUser(ctx) } diff --git a/packages/server/src/api/routes/public/index.ts b/packages/server/src/api/routes/public/index.ts index 531192811c..8e4533f156 100644 --- a/packages/server/src/api/routes/public/index.ts +++ b/packages/server/src/api/routes/public/index.ts @@ -12,6 +12,7 @@ import { paramResource, paramSubResource } from "../../../middleware/resourceId" import { PermissionLevel, PermissionType } from "@budibase/types" import { CtxFn } from "./utils/Endpoint" import mapperMiddleware from "./middleware/mapper" +import testErrorHandling from "./middleware/testErrorHandling" import env from "../../../environment" import { middleware, redis } from "@budibase/backend-core" import { SelectableDatabase } from "@budibase/backend-core/src/redis/utils" @@ -144,6 +145,10 @@ function applyRoutes( // add the output mapper middleware addMiddleware(endpoints.read, mapperMiddleware, { output: true }) addMiddleware(endpoints.write, mapperMiddleware, { output: true }) + if (env.isTest()) { + addMiddleware(endpoints.read, testErrorHandling()) + addMiddleware(endpoints.write, testErrorHandling()) + } addToRouter(endpoints.read) addToRouter(endpoints.write) } diff --git a/packages/server/src/api/routes/public/middleware/testErrorHandling.ts b/packages/server/src/api/routes/public/middleware/testErrorHandling.ts new file mode 100644 index 0000000000..b22acae8a8 --- /dev/null +++ b/packages/server/src/api/routes/public/middleware/testErrorHandling.ts @@ -0,0 +1,28 @@ +import { Ctx } from "@budibase/types" +import environment from "../../../../environment" + +export default () => { + if (!environment.isTest()) { + throw new Error("This middleware is only for testing") + } + + return async (ctx: Ctx, next: any) => { + try { + await next() + } catch (err: any) { + if (!ctx.headers["x-budibase-include-stacktrace"]) { + throw err + } + + const status = err.status || err.statusCode || 500 + + let error = err + while (error.cause) { + error = error.cause + } + + ctx.status = status + ctx.body = { status, message: error.message, stack: error.stack } + } + } +} diff --git a/packages/server/src/api/routes/public/tests/compare.spec.ts b/packages/server/src/api/routes/public/tests/compare.spec.ts index ccf248f360..b4f5f20e2c 100644 --- a/packages/server/src/api/routes/public/tests/compare.spec.ts +++ b/packages/server/src/api/routes/public/tests/compare.spec.ts @@ -2,184 +2,174 @@ import jestOpenAPI from "jest-openapi" import { run as generateSchema } from "../../../../../specs/generate" import * as setup from "../../tests/utilities" import { generateMakeRequest } from "./utils" -import { Table, App, Row, User } from "@budibase/types" +import { Table, App, Row } from "@budibase/types" +import nock from "nock" +import environment from "../../../../environment" const yamlPath = generateSchema() jestOpenAPI(yamlPath!) -let config = setup.getConfig() -let apiKey: string, table: Table, app: App, makeRequest: any +describe("compare", () => { + let config = setup.getConfig() + let apiKey: string, table: Table, app: App, makeRequest: any -beforeAll(async () => { - app = await config.init() - table = await config.upsertTable() - apiKey = await config.generateApiKey() - makeRequest = generateMakeRequest(apiKey) -}) - -afterAll(setup.afterAll) - -describe("check the applications endpoints", () => { - it("should allow retrieving applications through search", async () => { - const res = await makeRequest("post", "/applications/search") - expect(res).toSatisfyApiSpec() - }) - - it("should allow creating an application", async () => { - const res = await makeRequest( - "post", - "/applications", - { - name: "new App", - }, - null - ) - expect(res).toSatisfyApiSpec() - }) - - it("should allow updating an application", async () => { - const app = config.getApp() - const appId = config.getAppId() - const res = await makeRequest( - "put", - `/applications/${appId}`, - { - ...app, - name: "updated app name", - }, - appId - ) - expect(res).toSatisfyApiSpec() - }) - - it("should allow retrieving an application", async () => { - const res = await makeRequest("get", `/applications/${config.getAppId()}`) - expect(res).toSatisfyApiSpec() - }) - - it("should allow deleting an application", async () => { - const res = await makeRequest( - "delete", - `/applications/${config.getAppId()}` - ) - expect(res).toSatisfyApiSpec() - }) -}) - -describe("check the tables endpoints", () => { - it("should allow retrieving tables through search", async () => { - await config.createApp("new app 1") + beforeAll(async () => { + app = await config.init() table = await config.upsertTable() - const res = await makeRequest("post", "/tables/search") - expect(res).toSatisfyApiSpec() + apiKey = await config.generateApiKey() + makeRequest = generateMakeRequest(apiKey) }) - it("should allow creating a table", async () => { - const res = await makeRequest("post", "/tables", { - name: "table name", - primaryDisplay: "column1", - schema: { - column1: { - type: "string", - constraints: {}, + afterAll(setup.afterAll) + + beforeEach(() => { + nock.cleanAll() + }) + + describe("check the applications endpoints", () => { + it("should allow retrieving applications through search", async () => { + const res = await makeRequest("post", "/applications/search") + expect(res).toSatisfyApiSpec() + }) + + it("should allow creating an application", async () => { + const res = await makeRequest( + "post", + "/applications", + { + name: "new App", }, - }, + null + ) + expect(res).toSatisfyApiSpec() }) - expect(res).toSatisfyApiSpec() - }) - it("should allow updating a table", async () => { - const updated = { ...table, _rev: undefined, name: "new name" } - const res = await makeRequest("put", `/tables/${table._id}`, updated) - expect(res).toSatisfyApiSpec() - }) - - it("should allow retrieving a table", async () => { - const res = await makeRequest("get", `/tables/${table._id}`) - expect(res).toSatisfyApiSpec() - }) - - it("should allow deleting a table", async () => { - const res = await makeRequest("delete", `/tables/${table._id}`) - expect(res).toSatisfyApiSpec() - }) -}) - -describe("check the rows endpoints", () => { - let row: Row - it("should allow retrieving rows through search", async () => { - table = await config.upsertTable() - const res = await makeRequest("post", `/tables/${table._id}/rows/search`, { - query: {}, + it("should allow updating an application", async () => { + const app = config.getApp() + const appId = config.getAppId() + const res = await makeRequest( + "put", + `/applications/${appId}`, + { + ...app, + name: "updated app name", + }, + appId + ) + expect(res).toSatisfyApiSpec() }) - expect(res).toSatisfyApiSpec() - }) - it("should allow creating a row", async () => { - const res = await makeRequest("post", `/tables/${table._id}/rows`, { - name: "test row", + it("should allow retrieving an application", async () => { + const res = await makeRequest("get", `/applications/${config.getAppId()}`) + expect(res).toSatisfyApiSpec() + }) + + it("should allow deleting an application", async () => { + nock(environment.WORKER_URL!) + .delete(`/api/global/roles/${config.getProdAppId()}`) + .reply(200, {}) + + const res = await makeRequest( + "delete", + `/applications/${config.getAppId()}` + ) + expect(res).toSatisfyApiSpec() }) - expect(res).toSatisfyApiSpec() - row = res.body.data }) - it("should allow updating a row", async () => { - const res = await makeRequest( - "put", - `/tables/${table._id}/rows/${row._id}`, - { - name: "test row updated", - } - ) - expect(res).toSatisfyApiSpec() + describe("check the tables endpoints", () => { + it("should allow retrieving tables through search", async () => { + await config.createApp("new app 1") + table = await config.upsertTable() + const res = await makeRequest("post", "/tables/search") + expect(res).toSatisfyApiSpec() + }) + + it("should allow creating a table", async () => { + const res = await makeRequest("post", "/tables", { + name: "table name", + primaryDisplay: "column1", + schema: { + column1: { + type: "string", + constraints: {}, + }, + }, + }) + expect(res).toSatisfyApiSpec() + }) + + it("should allow updating a table", async () => { + const updated = { ...table, _rev: undefined, name: "new name" } + const res = await makeRequest("put", `/tables/${table._id}`, updated) + expect(res).toSatisfyApiSpec() + }) + + it("should allow retrieving a table", async () => { + const res = await makeRequest("get", `/tables/${table._id}`) + expect(res).toSatisfyApiSpec() + }) + + it("should allow deleting a table", async () => { + const res = await makeRequest("delete", `/tables/${table._id}`) + expect(res).toSatisfyApiSpec() + }) }) - it("should allow retrieving a row", async () => { - const res = await makeRequest("get", `/tables/${table._id}/rows/${row._id}`) - expect(res).toSatisfyApiSpec() + describe("check the rows endpoints", () => { + let row: Row + it("should allow retrieving rows through search", async () => { + table = await config.upsertTable() + const res = await makeRequest( + "post", + `/tables/${table._id}/rows/search`, + { + query: {}, + } + ) + expect(res).toSatisfyApiSpec() + }) + + it("should allow creating a row", async () => { + const res = await makeRequest("post", `/tables/${table._id}/rows`, { + name: "test row", + }) + expect(res).toSatisfyApiSpec() + row = res.body.data + }) + + it("should allow updating a row", async () => { + const res = await makeRequest( + "put", + `/tables/${table._id}/rows/${row._id}`, + { + name: "test row updated", + } + ) + expect(res).toSatisfyApiSpec() + }) + + it("should allow retrieving a row", async () => { + const res = await makeRequest( + "get", + `/tables/${table._id}/rows/${row._id}` + ) + expect(res).toSatisfyApiSpec() + }) + + it("should allow deleting a row", async () => { + const res = await makeRequest( + "delete", + `/tables/${table._id}/rows/${row._id}` + ) + expect(res).toSatisfyApiSpec() + }) }) - it("should allow deleting a row", async () => { - const res = await makeRequest( - "delete", - `/tables/${table._id}/rows/${row._id}` - ) - expect(res).toSatisfyApiSpec() - }) -}) - -describe("check the users endpoints", () => { - let user: User - it("should allow retrieving users through search", async () => { - user = await config.createUser() - const res = await makeRequest("post", "/users/search") - expect(res).toSatisfyApiSpec() - }) - - it("should allow creating a user", async () => { - const res = await makeRequest("post", "/users") - expect(res).toSatisfyApiSpec() - }) - - it("should allow updating a user", async () => { - const res = await makeRequest("put", `/users/${user._id}`) - expect(res).toSatisfyApiSpec() - }) - - it("should allow retrieving a user", async () => { - const res = await makeRequest("get", `/users/${user._id}`) - expect(res).toSatisfyApiSpec() - }) - - it("should allow deleting a user", async () => { - const res = await makeRequest("delete", `/users/${user._id}`) - expect(res).toSatisfyApiSpec() - }) -}) - -describe("check the queries endpoints", () => { - it("should allow retrieving queries through search", async () => { - const res = await makeRequest("post", "/queries/search") - expect(res).toSatisfyApiSpec() + describe("check the queries endpoints", () => { + it("should allow retrieving queries through search", async () => { + const res = await makeRequest("post", "/queries/search") + expect(res).toSatisfyApiSpec() + }) }) }) diff --git a/packages/server/src/api/routes/public/tests/users.spec.ts b/packages/server/src/api/routes/public/tests/users.spec.ts index 4ca9ff8104..bb3e4b6fba 100644 --- a/packages/server/src/api/routes/public/tests/users.spec.ts +++ b/packages/server/src/api/routes/public/tests/users.spec.ts @@ -1,132 +1,143 @@ import * as setup from "../../tests/utilities" -import { generateMakeRequest, MakeRequestResponse } from "./utils" import { User } from "@budibase/types" -import { mocks } from "@budibase/backend-core/tests" +import { generator, mocks } from "@budibase/backend-core/tests" +import nock from "nock" +import TestConfiguration from "../../../../tests/utilities/TestConfiguration" +import { mockWorkerUserAPI } from "./utils" -import * as workerRequests from "../../../../utilities/workerRequests" +describe("public users API", () => { + const config = new TestConfiguration() + let globalUser: User -const mockedWorkerReq = jest.mocked(workerRequests) - -let config = setup.getConfig() -let apiKey: string, globalUser: User, makeRequest: MakeRequestResponse - -beforeAll(async () => { - await config.init() - globalUser = await config.globalUser() - apiKey = await config.generateApiKey(globalUser._id) - makeRequest = generateMakeRequest(apiKey) - mockedWorkerReq.readGlobalUser.mockImplementation(() => - Promise.resolve(globalUser) - ) -}) - -afterAll(setup.afterAll) - -function base() { - return { - tenantId: config.getTenantId(), - firstName: "Test", - lastName: "Test", - } -} - -function updateMock() { - mockedWorkerReq.readGlobalUser.mockImplementation(ctx => ctx.request.body) -} - -describe("check user endpoints", () => { - it("should not allow a user to update their own roles", async () => { - const res = await makeRequest("put", `/users/${globalUser._id}`, { - ...globalUser, - roles: { - app_1: "ADMIN", - }, - }) - expect( - mockedWorkerReq.saveGlobalUser.mock.lastCall?.[0].body.data.roles["app_1"] - ).toBeUndefined() - expect(res.status).toBe(200) - expect(res.body.data.roles["app_1"]).toBeUndefined() + beforeAll(async () => { + await config.init() }) - it("should not allow a user to delete themselves", async () => { - const res = await makeRequest("delete", `/users/${globalUser._id}`) - expect(res.status).toBe(405) - expect(mockedWorkerReq.deleteGlobalUser.mock.lastCall).toBeUndefined() - }) -}) - -describe("no user role update in free", () => { - beforeAll(() => { - updateMock() - }) - - it("should not allow 'roles' to be updated", async () => { - const res = await makeRequest("post", "/users", { - ...base(), - roles: { app_a: "BASIC" }, - }) - expect(res.status).toBe(200) - expect(res.body.data.roles["app_a"]).toBeUndefined() - expect(res.body.message).toBeDefined() - }) - - it("should not allow 'admin' to be updated", async () => { - const res = await makeRequest("post", "/users", { - ...base(), - admin: { global: true }, - }) - expect(res.status).toBe(200) - expect(res.body.data.admin).toBeUndefined() - expect(res.body.message).toBeDefined() - }) - - it("should not allow 'builder' to be updated", async () => { - const res = await makeRequest("post", "/users", { - ...base(), - builder: { global: true }, - }) - expect(res.status).toBe(200) - expect(res.body.data.builder).toBeUndefined() - expect(res.body.message).toBeDefined() - }) -}) - -describe("no user role update in business", () => { - beforeAll(() => { - updateMock() - mocks.licenses.useExpandedPublicApi() - }) - - it("should allow 'roles' to be updated", async () => { - const res = await makeRequest("post", "/users", { - ...base(), - roles: { app_a: "BASIC" }, - }) - expect(res.status).toBe(200) - expect(res.body.data.roles["app_a"]).toBe("BASIC") - expect(res.body.message).toBeUndefined() - }) - - it("should allow 'admin' to be updated", async () => { - mocks.licenses.useExpandedPublicApi() - const res = await makeRequest("post", "/users", { - ...base(), - admin: { global: true }, - }) - expect(res.status).toBe(200) - expect(res.body.data.admin.global).toBe(true) - expect(res.body.message).toBeUndefined() - }) - - it("should allow 'builder' to be updated", async () => { - mocks.licenses.useExpandedPublicApi() - const res = await makeRequest("post", "/users", { - ...base(), - builder: { global: true }, - }) - expect(res.status).toBe(200) - expect(res.body.data.builder.global).toBe(true) - expect(res.body.message).toBeUndefined() + afterAll(setup.afterAll) + + beforeEach(async () => { + globalUser = await config.globalUser() + + nock.cleanAll() + mockWorkerUserAPI(globalUser) + }) + + describe("read", () => { + it("should allow a user to read themselves", async () => { + const user = await config.api.user.find(globalUser._id!) + expect(user._id).toBe(globalUser._id) + }) + + it("should allow a user to read another user", async () => { + const otherUser = await config.api.public.user.create({ + email: generator.email({ domain: "example.com" }), + roles: {}, + }) + const user = await config.withUser(globalUser, () => + config.api.public.user.find(otherUser._id!) + ) + expect(user._id).toBe(otherUser._id) + }) + }) + + describe("create", () => { + it("can successfully create a new user", async () => { + const email = generator.email({ domain: "example.com" }) + const newUser = await config.api.public.user.create({ + email, + roles: {}, + }) + expect(newUser.email).toBe(email) + expect(newUser._id).toBeDefined() + }) + + describe("role creation on free tier", () => { + it("should not allow 'roles' to be updated", async () => { + const newUser = await config.api.public.user.create({ + email: generator.email({ domain: "example.com" }), + roles: { app_a: "BASIC" }, + }) + expect(newUser.roles["app_a"]).toBeUndefined() + }) + + it("should not allow 'admin' to be updated", async () => { + const newUser = await config.api.public.user.create({ + email: generator.email({ domain: "example.com" }), + roles: {}, + admin: { global: true }, + }) + expect(newUser.admin).toBeUndefined() + }) + + it("should not allow 'builder' to be updated", async () => { + const newUser = await config.api.public.user.create({ + email: generator.email({ domain: "example.com" }), + roles: {}, + builder: { global: true }, + }) + expect(newUser.builder).toBeUndefined() + }) + }) + + describe("role creation on business tier", () => { + beforeAll(() => { + mocks.licenses.useExpandedPublicApi() + }) + + it("should allow 'roles' to be updated", async () => { + const newUser = await config.api.public.user.create({ + email: generator.email({ domain: "example.com" }), + roles: { app_a: "BASIC" }, + }) + expect(newUser.roles["app_a"]).toBe("BASIC") + }) + + it("should allow 'admin' to be updated", async () => { + const newUser = await config.api.public.user.create({ + email: generator.email({ domain: "example.com" }), + roles: {}, + admin: { global: true }, + }) + expect(newUser.admin?.global).toBe(true) + }) + + it("should allow 'builder' to be updated", async () => { + const newUser = await config.api.public.user.create({ + email: generator.email({ domain: "example.com" }), + roles: {}, + builder: { global: true }, + }) + expect(newUser.builder?.global).toBe(true) + }) + }) + }) + + describe("update", () => { + it("can update a user", async () => { + const updatedUser = await config.api.public.user.update({ + ...globalUser, + email: `updated-${globalUser.email}`, + }) + expect(updatedUser.email).toBe(`updated-${globalUser.email}`) + }) + + it("should not allow a user to update their own roles", async () => { + await config.withUser(globalUser, () => + config.api.public.user.update({ + ...globalUser, + roles: { app_1: "ADMIN" }, + }) + ) + const updatedUser = await config.api.user.find(globalUser._id!) + expect(updatedUser.roles?.app_1).toBeUndefined() + }) + }) + + describe("delete", () => { + it("should not allow a user to delete themselves", async () => { + await config.withUser(globalUser, () => + config.api.public.user.destroy(globalUser._id!, { status: 405 }) + ) + }) }) }) diff --git a/packages/server/src/api/routes/public/tests/utils.ts b/packages/server/src/api/routes/public/tests/utils.ts index 4fb048a540..4985c0db58 100644 --- a/packages/server/src/api/routes/public/tests/utils.ts +++ b/packages/server/src/api/routes/public/tests/utils.ts @@ -1,6 +1,10 @@ import * as setup from "../../tests/utilities" import { checkSlashesInUrl } from "../../../../utilities" import supertest from "supertest" +import { User } from "@budibase/types" +import environment from "../../../../environment" +import nock from "nock" +import { generator } from "@budibase/backend-core/tests" export type HttpMethod = "post" | "get" | "put" | "delete" | "patch" @@ -91,3 +95,43 @@ export function generateMakeRequestWithFormData( return res } } + +export function mockWorkerUserAPI(...seedUsers: User[]) { + const users: Record = { + ...seedUsers.reduce((acc, user) => { + acc[user._id!] = user + return acc + }, {} as Record), + } + + nock(environment.WORKER_URL!) + .get(new RegExp(`/api/global/users/.*`)) + .reply(200, (uri, body) => { + const id = uri.split("/").pop() + return users[id!] + }) + .persist() + + nock(environment.WORKER_URL!) + .post(`/api/global/users`) + .reply(200, (uri, body) => { + const newUser = body as User + if (!newUser._id) { + newUser._id = `us_${generator.guid()}` + } + users[newUser._id!] = newUser + return newUser + }) + .persist() + + nock(environment.WORKER_URL!) + .put(new RegExp(`/api/global/users/.*`)) + .reply(200, (uri, body) => { + const id = uri.split("/").pop()! + const updatedUser = body as User + const existingUser = users[id] || {} + users[id] = { ...existingUser, ...updatedUser } + return users[id] + }) + .persist() +} diff --git a/packages/server/src/api/routes/tests/utilities/index.ts b/packages/server/src/api/routes/tests/utilities/index.ts index dcb8ccd6c0..944a56d7ba 100644 --- a/packages/server/src/api/routes/tests/utilities/index.ts +++ b/packages/server/src/api/routes/tests/utilities/index.ts @@ -3,44 +3,6 @@ import supertest from "supertest" export * as structures from "../../../../tests/utilities/structures" -function user() { - return { - _id: "user", - _rev: "rev", - createdAt: Date.now(), - email: "test@example.com", - roles: {}, - tenantId: "default", - status: "active", - } -} - -jest.mock("../../../../utilities/workerRequests", () => ({ - getGlobalUsers: jest.fn(() => { - return { - _id: "us_uuid1", - } - }), - getGlobalSelf: jest.fn(() => { - return { - _id: "us_uuid1", - } - }), - allGlobalUsers: jest.fn(() => { - return [user()] - }), - readGlobalUser: jest.fn(() => { - return user() - }), - saveGlobalUser: jest.fn(() => { - return { _id: "user", _rev: "rev" } - }), - deleteGlobalUser: jest.fn(() => { - return { message: "deleted user" } - }), - removeAppFromUserRoles: jest.fn(), -})) - export function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) } diff --git a/packages/server/src/tests/utilities/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts index 0d1330b4f4..273760cb34 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.ts +++ b/packages/server/src/tests/utilities/TestConfiguration.ts @@ -67,6 +67,7 @@ import { View, Webhook, WithRequired, + DevInfo, } from "@budibase/types" import API from "./api" @@ -249,7 +250,7 @@ export default class TestConfiguration { } } - async withUser(user: User, f: () => Promise) { + async withUser(user: User, f: () => Promise): Promise { const oldUser = this.user this.user = user try { @@ -470,7 +471,10 @@ export default class TestConfiguration { } } - defaultHeaders(extras = {}, prodApp = false) { + defaultHeaders( + extras: Record = {}, + prodApp = false + ) { const tenantId = this.getTenantId() const user = this.getUser() const authObj: AuthToken = { @@ -499,10 +503,13 @@ export default class TestConfiguration { } } - publicHeaders({ prodApp = true } = {}) { + publicHeaders({ + prodApp = true, + extras = {}, + }: { prodApp?: boolean; extras?: Record } = {}) { const appId = prodApp ? this.prodAppId : this.appId - const headers: any = { + const headers: Record = { Accept: "application/json", Cookie: "", } @@ -515,6 +522,7 @@ export default class TestConfiguration { return { ...headers, ...this.temporaryHeaders, + ...extras, } } @@ -578,17 +586,17 @@ export default class TestConfiguration { } const db = tenancy.getTenantDB(this.getTenantId()) const id = dbCore.generateDevInfoID(userId) - let devInfo: any - try { - devInfo = await db.get(id) - } catch (err) { - devInfo = { _id: id, userId } + const devInfo = await db.tryGet(id) + if (devInfo && devInfo.apiKey) { + return devInfo.apiKey } - devInfo.apiKey = encryption.encrypt( + + const apiKey = encryption.encrypt( `${this.getTenantId()}${dbCore.SEPARATOR}${newid()}` ) - await db.put(devInfo) - return devInfo.apiKey + const newDevInfo: DevInfo = { _id: id, userId, apiKey } + await db.put(newDevInfo) + return apiKey } // APP diff --git a/packages/server/src/tests/utilities/api/base.ts b/packages/server/src/tests/utilities/api/base.ts index 39ac5cefc0..9b47cfb820 100644 --- a/packages/server/src/tests/utilities/api/base.ts +++ b/packages/server/src/tests/utilities/api/base.ts @@ -1,8 +1,12 @@ +import jestOpenAPI from "jest-openapi" +import { spec } from "../../../../specs/generate" import TestConfiguration from "../TestConfiguration" import request, { SuperTest, Test, Response } from "supertest" import { ReadStream } from "fs" import { getServer } from "../../../app" +jestOpenAPI(spec() as any) + type Headers = Record type Method = "get" | "post" | "put" | "patch" | "delete" @@ -46,6 +50,7 @@ export interface RequestOpts { export abstract class TestAPI { config: TestConfiguration request: SuperTest + prefix = "" constructor(config: TestConfiguration) { this.config = config @@ -53,26 +58,26 @@ export abstract class TestAPI { } protected _get = async (url: string, opts?: RequestOpts): Promise => { - return await this._request("get", url, opts) + return await this._request("get", `${this.prefix}${url}`, opts) } protected _post = async (url: string, opts?: RequestOpts): Promise => { - return await this._request("post", url, opts) + return await this._request("post", `${this.prefix}${url}`, opts) } protected _put = async (url: string, opts?: RequestOpts): Promise => { - return await this._request("put", url, opts) + return await this._request("put", `${this.prefix}${url}`, opts) } protected _patch = async (url: string, opts?: RequestOpts): Promise => { - return await this._request("patch", url, opts) + return await this._request("patch", `${this.prefix}${url}`, opts) } protected _delete = async ( url: string, opts?: RequestOpts ): Promise => { - return await this._request("delete", url, opts) + return await this._request("delete", `${this.prefix}${url}`, opts) } protected _requestRaw = async ( @@ -88,7 +93,6 @@ export abstract class TestAPI { fields = {}, files = {}, expectations, - publicUser = false, } = opts || {} const { status = 200 } = expectations || {} const expectHeaders = expectations?.headers || {} @@ -97,7 +101,7 @@ export abstract class TestAPI { expectHeaders["Content-Type"] = /^application\/json/ } - let queryParams = [] + let queryParams: string[] = [] for (const [key, value] of Object.entries(query)) { if (value) { queryParams.push(`${key}=${value}`) @@ -107,18 +111,10 @@ export abstract class TestAPI { url += `?${queryParams.join("&")}` } - const headersFn = publicUser - ? (_extras = {}) => - this.config.publicHeaders.bind(this.config)({ - prodApp: opts?.useProdApp, - }) - : (extras = {}) => - this.config.defaultHeaders.bind(this.config)(extras, opts?.useProdApp) - const app = getServer() let req = request(app)[method](url) req = req.set( - headersFn({ + await this.getHeaders(opts, { "x-budibase-include-stacktrace": "true", }) ) @@ -167,10 +163,18 @@ export abstract class TestAPI { } } - protected _checkResponse = ( - response: Response, - expectations?: Expectations - ) => { + protected async getHeaders( + opts?: RequestOpts, + extras?: Record + ): Promise> { + if (opts?.publicUser) { + return this.config.publicHeaders({ prodApp: opts?.useProdApp, extras }) + } else { + return this.config.defaultHeaders(extras, opts?.useProdApp) + } + } + + protected _checkResponse(response: Response, expectations?: Expectations) { const { status = 200 } = expectations || {} if (response.status !== status) { @@ -236,3 +240,34 @@ export abstract class TestAPI { ).body } } + +export abstract class PublicAPI extends TestAPI { + prefix = "/api/public/v1" + + protected async getHeaders( + opts?: RequestOpts, + extras?: Record + ): Promise> { + const apiKey = await this.config.generateApiKey() + + const headers: Record = { + Accept: "application/json", + Host: this.config.tenantHost(), + "x-budibase-api-key": apiKey, + "x-budibase-app-id": this.config.getAppId(), + ...extras, + } + + return headers + } + + protected _checkResponse(response: Response, expectations?: Expectations) { + const checked = super._checkResponse(response, expectations) + if (checked.status >= 200 && checked.status < 300) { + // We don't seem to have documented our errors yet, so for the time being + // we'll only do the schema check for successful responses. + expect(checked).toSatisfyApiSpec() + } + return checked + } +} diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts index 2fdf726b6c..1252b10e40 100644 --- a/packages/server/src/tests/utilities/api/index.ts +++ b/packages/server/src/tests/utilities/api/index.ts @@ -17,6 +17,7 @@ import { RowActionAPI } from "./rowAction" import { AutomationAPI } from "./automation" import { PluginAPI } from "./plugin" import { WebhookAPI } from "./webhook" +import { UserPublicAPI } from "./public/user" export default class API { application: ApplicationAPI @@ -38,6 +39,10 @@ export default class API { viewV2: ViewV2API webhook: WebhookAPI + public: { + user: UserPublicAPI + } + constructor(config: TestConfiguration) { this.application = new ApplicationAPI(config) this.attachment = new AttachmentAPI(config) @@ -57,5 +62,8 @@ export default class API { this.user = new UserAPI(config) this.viewV2 = new ViewV2API(config) this.webhook = new WebhookAPI(config) + this.public = { + user: new UserPublicAPI(config), + } } } diff --git a/packages/server/src/tests/utilities/api/public/user.ts b/packages/server/src/tests/utilities/api/public/user.ts new file mode 100644 index 0000000000..4f5fccc740 --- /dev/null +++ b/packages/server/src/tests/utilities/api/public/user.ts @@ -0,0 +1,34 @@ +import { UnsavedUser, User } from "@budibase/types" +import { Expectations, PublicAPI } from "../base" + +export class UserPublicAPI extends PublicAPI { + find = async (id: string, expectations?: Expectations): Promise => { + const response = await this._get<{ data: User }>(`/users/${id}`, { + expectations, + }) + return response.data + } + + update = async (user: User, expectations?: Expectations): Promise => { + const response = await this._put<{ data: User }>(`/users/${user._id}`, { + body: user, + expectations, + }) + return response.data + } + + destroy = async (id: string, expectations?: Expectations): Promise => { + return await this._delete(`/users/${id}`, { expectations }) + } + + create = async ( + user: UnsavedUser, + expectations?: Expectations + ): Promise => { + const response = await this._post<{ data: User }>("/users", { + body: user, + expectations, + }) + return response.data + } +} diff --git a/yarn.lock b/yarn.lock index 279c4067a4..5173e81408 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2796,9 +2796,9 @@ through2 "^2.0.0" "@budibase/pro@npm:@budibase/pro@latest": - version "3.4.20" - resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.20.tgz#0d855d6ed8fe92fd178c74a8963d879cc124b034" - integrity sha512-hUteGvhMOKjBo0fluxcqNs7d4x8OU5W8Oqqrm7eIS9Ohe7ala2iWNCcrj+x+S9CavIm6s7JZZnAewa2Maiz2zQ== + version "3.4.22" + resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.22.tgz#943f23cb7056041bc1f433ee60b3d093145e7a4a" + integrity sha512-Du3iZsmRLopfoi2SvxQyY1P2Su3Nw0WbITOrKmZFsVLjZ9MzzTZs0Ph/SJHzrfJpM7rn9+8788BLSf3Z3l9KcQ== dependencies: "@anthropic-ai/sdk" "^0.27.3" "@budibase/backend-core" "*" @@ -7142,6 +7142,11 @@ dependencies: "@types/superagent" "*" +"@types/swagger-jsdoc@^6.0.4": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz#bb4f60f3a5f103818e022f2e29ff8935113fb83d" + integrity sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ== + "@types/tar-fs@2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-2.0.1.tgz#6391dcad1b03dea2d79fac07371585ab54472bb1"