diff --git a/packages/server/src/api/controllers/auth.ts b/packages/server/src/api/controllers/auth.ts index eabfe10bab..9b1b78ed9e 100644 --- a/packages/server/src/api/controllers/auth.ts +++ b/packages/server/src/api/controllers/auth.ts @@ -26,7 +26,7 @@ export async function fetchSelf(ctx: UserCtx) { } const appId = context.getAppId() - let user: ContextUser = await getFullUser(ctx, userId) + let user: ContextUser = await getFullUser(userId) // this shouldn't be returned by the app self delete user.roles // forward the csrf token from the session diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index d53345a239..fe7d94547a 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -2,7 +2,6 @@ import * as linkRows from "../../../db/linkedRows" import { generateRowID, getMultiIDParams, - getTableIDFromRowID, InternalTables, } from "../../../db/utils" import * as userController from "../user" @@ -89,7 +88,7 @@ export async function patch(ctx: UserCtx) { if (isUserTable) { // the row has been updated, need to put it into the ctx ctx.request.body = row as any - await userController.updateMetadata(ctx) + await userController.updateMetadata(ctx as any) return { row: ctx.body as Row, table } } diff --git a/packages/server/src/api/controllers/user.ts b/packages/server/src/api/controllers/user.ts index b6c3e7c6bd..108e29fd3d 100644 --- a/packages/server/src/api/controllers/user.ts +++ b/packages/server/src/api/controllers/user.ts @@ -1,14 +1,26 @@ import { generateUserFlagID, InternalTables } from "../../db/utils" import { getFullUser } from "../../utilities/users" import { context } from "@budibase/backend-core" -import { Ctx, UserCtx } from "@budibase/types" +import { + ContextUserMetadata, + Ctx, + FetchUserMetadataResponse, + FindUserMetadataResponse, + Flags, + SetFlagRequest, + UserCtx, + UserMetadata, +} from "@budibase/types" import sdk from "../../sdk" +import { DocumentInsertResponse } from "@budibase/nano" -export async function fetchMetadata(ctx: Ctx) { +export async function fetchMetadata(ctx: Ctx) { ctx.body = await sdk.users.fetchMetadata() } -export async function updateSelfMetadata(ctx: UserCtx) { +export async function updateSelfMetadata( + ctx: UserCtx +) { // overwrite the ID with current users ctx.request.body._id = ctx.user?._id // make sure no stale rev @@ -18,19 +30,21 @@ export async function updateSelfMetadata(ctx: UserCtx) { await updateMetadata(ctx) } -export async function updateMetadata(ctx: UserCtx) { +export async function updateMetadata( + ctx: UserCtx +) { const db = context.getAppDB() const user = ctx.request.body - // this isn't applicable to the user - delete user.roles - const metadata = { + const metadata: ContextUserMetadata = { tableId: InternalTables.USER_METADATA, ...user, } + // this isn't applicable to the user + delete metadata.roles ctx.body = await db.put(metadata) } -export async function destroyMetadata(ctx: UserCtx) { +export async function destroyMetadata(ctx: UserCtx) { const db = context.getAppDB() try { const dbUser = await sdk.users.get(ctx.params.id) @@ -43,11 +57,15 @@ export async function destroyMetadata(ctx: UserCtx) { } } -export async function findMetadata(ctx: UserCtx) { - ctx.body = await getFullUser(ctx, ctx.params.id) +export async function findMetadata( + ctx: UserCtx +) { + ctx.body = await getFullUser(ctx.params.id) } -export async function setFlag(ctx: UserCtx) { +export async function setFlag( + ctx: UserCtx +) { const userId = ctx.user?._id const { flag, value } = ctx.request.body if (!flag) { @@ -55,9 +73,9 @@ export async function setFlag(ctx: UserCtx) { } const flagDocId = generateUserFlagID(userId!) const db = context.getAppDB() - let doc + let doc: Flags try { - doc = await db.get(flagDocId) + doc = await db.get(flagDocId) } catch (err) { doc = { _id: flagDocId } } @@ -66,13 +84,13 @@ export async function setFlag(ctx: UserCtx) { ctx.body = { message: "Flag set successfully" } } -export async function getFlags(ctx: UserCtx) { +export async function getFlags(ctx: UserCtx) { const userId = ctx.user?._id const docId = generateUserFlagID(userId!) const db = context.getAppDB() - let doc + let doc: Flags try { - doc = await db.get(docId) + doc = await db.get(docId) } catch (err) { doc = { _id: docId } } diff --git a/packages/server/src/api/routes/tests/user.spec.js b/packages/server/src/api/routes/tests/user.spec.js deleted file mode 100644 index e8ffd8df2b..0000000000 --- a/packages/server/src/api/routes/tests/user.spec.js +++ /dev/null @@ -1,208 +0,0 @@ -const { roles, utils } = require("@budibase/backend-core") -const { checkPermissionsEndpoint } = require("./utilities/TestFunctions") -const setup = require("./utilities") -const { BUILTIN_ROLE_IDS } = roles - -jest.setTimeout(30000) - -jest.mock("../../../utilities/workerRequests", () => ({ - getGlobalUsers: jest.fn(() => { - return {} - }), - getGlobalSelf: jest.fn(() => { - return {} - }), - deleteGlobalUser: jest.fn(), -})) - -describe("/users", () => { - let request = setup.getRequest() - let config = setup.getConfig() - - afterAll(setup.afterAll) - - beforeAll(async () => { - await config.init() - }) - - describe("fetch", () => { - it("returns a list of users from an instance db", async () => { - await config.createUser({ id: "uuidx" }) - await config.createUser({ id: "uuidy" }) - const res = await request - .get(`/api/users/metadata`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - - expect(res.body.length).toBe(3) - expect(res.body.find(u => u._id === `ro_ta_users_us_uuidx`)).toBeDefined() - expect(res.body.find(u => u._id === `ro_ta_users_us_uuidy`)).toBeDefined() - }) - - it("should apply authorization to endpoint", async () => { - await config.createUser() - await checkPermissionsEndpoint({ - config, - request, - method: "GET", - url: `/api/users/metadata`, - passRole: BUILTIN_ROLE_IDS.ADMIN, - failRole: BUILTIN_ROLE_IDS.PUBLIC, - }) - }) - }) - - describe("update", () => { - it("should be able to update the user", async () => { - const user = await config.createUser({ id: `us_update${utils.newid()}` }) - user.roleId = BUILTIN_ROLE_IDS.BASIC - delete user._rev - const res = await request - .put(`/api/users/metadata`) - .set(config.defaultHeaders()) - .send(user) - .expect(200) - .expect("Content-Type", /json/) - expect(res.body.ok).toEqual(true) - }) - - it("should be able to update the user multiple times", async () => { - const user = await config.createUser() - delete user._rev - - const res1 = await request - .put(`/api/users/metadata`) - .set(config.defaultHeaders()) - .send({ ...user, roleId: BUILTIN_ROLE_IDS.BASIC }) - .expect(200) - .expect("Content-Type", /json/) - - const res = await request - .put(`/api/users/metadata`) - .set(config.defaultHeaders()) - .send({ ...user, _rev: res1.body.rev, roleId: BUILTIN_ROLE_IDS.POWER }) - .expect(200) - .expect("Content-Type", /json/) - - expect(res.body.ok).toEqual(true) - }) - - it("should require the _rev field for multiple updates", async () => { - const user = await config.createUser() - delete user._rev - - await request - .put(`/api/users/metadata`) - .set(config.defaultHeaders()) - .send({ ...user, roleId: BUILTIN_ROLE_IDS.BASIC }) - .expect(200) - .expect("Content-Type", /json/) - - await request - .put(`/api/users/metadata`) - .set(config.defaultHeaders()) - .send({ ...user, roleId: BUILTIN_ROLE_IDS.POWER }) - .expect(409) - .expect("Content-Type", /json/) - }) - }) - - describe("destroy", () => { - it("should be able to delete the user", async () => { - const user = await config.createUser() - const res = await request - .delete(`/api/users/metadata/${user._id}`) - .set(config.defaultHeaders()) - .expect(200) - .expect("Content-Type", /json/) - expect(res.body.message).toBeDefined() - }) - }) - - describe("find", () => { - it("should be able to find the user", async () => { - const user = await config.createUser() - const res = await request - .get(`/api/users/metadata/${user._id}`) - .set(config.defaultHeaders()) - .expect(200) - .expect("Content-Type", /json/) - expect(res.body._id).toEqual(user._id) - expect(res.body.roleId).toEqual(BUILTIN_ROLE_IDS.ADMIN) - expect(res.body.tableId).toBeDefined() - }) - }) - - describe("setFlag", () => { - it("should throw an error if a flag is not provided", async () => { - await config.createUser() - const res = await request - .post(`/api/users/flags`) - .set(config.defaultHeaders()) - .send({ value: "test" }) - .expect(400) - .expect("Content-Type", /json/) - expect(res.body.message).toEqual( - "Must supply a 'flag' field in request body." - ) - }) - - it("should be able to set a flag on the user", async () => { - await config.createUser() - const res = await request - .post(`/api/users/flags`) - .set(config.defaultHeaders()) - .send({ value: "test", flag: "test" }) - .expect(200) - .expect("Content-Type", /json/) - expect(res.body.message).toEqual("Flag set successfully") - }) - }) - - describe("getFlags", () => { - it("should get flags for a specific user", async () => { - let flagData = { value: "test", flag: "test" } - await config.createUser() - await request - .post(`/api/users/flags`) - .set(config.defaultHeaders()) - .send(flagData) - .expect(200) - .expect("Content-Type", /json/) - - const res = await request - .get(`/api/users/flags`) - .set(config.defaultHeaders()) - .expect(200) - .expect("Content-Type", /json/) - expect(res.body[flagData.value]).toEqual(flagData.flag) - }) - }) - - describe("setFlag", () => { - it("should throw an error if a flag is not provided", async () => { - await config.createUser() - const res = await request - .post(`/api/users/flags`) - .set(config.defaultHeaders()) - .send({ value: "test" }) - .expect(400) - .expect("Content-Type", /json/) - expect(res.body.message).toEqual( - "Must supply a 'flag' field in request body." - ) - }) - - it("should be able to set a flag on the user", async () => { - await config.createUser() - const res = await request - .post(`/api/users/flags`) - .set(config.defaultHeaders()) - .send({ value: "test", flag: "test" }) - .expect(200) - .expect("Content-Type", /json/) - expect(res.body.message).toEqual("Flag set successfully") - }) - }) -}) diff --git a/packages/server/src/api/routes/tests/user.spec.ts b/packages/server/src/api/routes/tests/user.spec.ts new file mode 100644 index 0000000000..e6349099d7 --- /dev/null +++ b/packages/server/src/api/routes/tests/user.spec.ts @@ -0,0 +1,144 @@ +import { roles, utils } from "@budibase/backend-core" +import { checkPermissionsEndpoint } from "./utilities/TestFunctions" +import * as setup from "./utilities" +import { UserMetadata } from "@budibase/types" + +jest.setTimeout(30000) + +jest.mock("../../../utilities/workerRequests", () => ({ + getGlobalUsers: jest.fn(() => { + return {} + }), + getGlobalSelf: jest.fn(() => { + return {} + }), + deleteGlobalUser: jest.fn(), +})) + +describe("/users", () => { + let request = setup.getRequest() + let config = setup.getConfig() + + afterAll(setup.afterAll) + + beforeAll(async () => { + await config.init() + }) + + describe("fetch", () => { + it("returns a list of users from an instance db", async () => { + await config.createUser({ id: "uuidx" }) + await config.createUser({ id: "uuidy" }) + + const res = await config.api.user.fetch() + expect(res.length).toBe(3) + + const ids = res.map(u => u._id) + expect(ids).toContain(`ro_ta_users_us_uuidx`) + expect(ids).toContain(`ro_ta_users_us_uuidy`) + }) + + it("should apply authorization to endpoint", async () => { + await config.createUser() + await checkPermissionsEndpoint({ + config, + request, + method: "GET", + url: `/api/users/metadata`, + passRole: roles.BUILTIN_ROLE_IDS.ADMIN, + failRole: roles.BUILTIN_ROLE_IDS.PUBLIC, + }) + }) + }) + + describe("update", () => { + it("should be able to update the user", async () => { + const user: UserMetadata = await config.createUser({ + id: `us_update${utils.newid()}`, + }) + user.roleId = roles.BUILTIN_ROLE_IDS.BASIC + delete user._rev + const res = await config.api.user.update(user) + expect(res.ok).toEqual(true) + }) + + it("should be able to update the user multiple times", async () => { + const user = await config.createUser() + delete user._rev + + const res1 = await config.api.user.update({ + ...user, + roleId: roles.BUILTIN_ROLE_IDS.BASIC, + }) + const res2 = await config.api.user.update({ + ...user, + _rev: res1.rev, + roleId: roles.BUILTIN_ROLE_IDS.POWER, + }) + expect(res2.ok).toEqual(true) + }) + + it("should require the _rev field for multiple updates", async () => { + const user = await config.createUser() + delete user._rev + + await config.api.user.update({ + ...user, + roleId: roles.BUILTIN_ROLE_IDS.BASIC, + }) + await config.api.user.update( + { ...user, roleId: roles.BUILTIN_ROLE_IDS.POWER }, + { expectStatus: 409 } + ) + }) + }) + + describe("destroy", () => { + it("should be able to delete the user", async () => { + const user = await config.createUser() + const res = await config.api.user.destroy(user._id!) + expect(res.message).toBeDefined() + }) + }) + + describe("find", () => { + it("should be able to find the user", async () => { + const user = await config.createUser() + const res = await config.api.user.find(user._id!) + expect(res._id).toEqual(user._id) + expect(res.roleId).toEqual(roles.BUILTIN_ROLE_IDS.ADMIN) + expect(res.tableId).toBeDefined() + }) + }) + + describe("setFlag", () => { + it("should throw an error if a flag is not provided", async () => { + await config.createUser() + const res = await request + .post(`/api/users/flags`) + .set(config.defaultHeaders()) + .send({ value: "test" }) + .expect(400) + .expect("Content-Type", /json/) + expect(res.body.message).toEqual( + "Must supply a 'flag' field in request body." + ) + }) + + it("should be able to set a flag on the user", async () => { + await config.createUser() + const res = await config.api.user.setFlag("test", true) + expect(res.message).toEqual("Flag set successfully") + }) + }) + + describe("getFlags", () => { + it("should get flags for a specific user", async () => { + await config.createUser() + await config.api.user.setFlag("test", "test") + + const res = await config.api.user.getFlags() + expect(res.test).toEqual("test") + }) + }) +}) diff --git a/packages/server/src/tests/utilities/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts index 04c0552457..6877561fcb 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.ts +++ b/packages/server/src/tests/utilities/TestConfiguration.ts @@ -264,7 +264,7 @@ class TestConfiguration { admin = false, email = this.defaultUserValues.email, roles, - }: any = {}) { + }: any = {}): Promise { const db = tenancy.getTenantDB(this.getTenantId()) let existing try { diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts index c553e7b8f4..20b96f7a99 100644 --- a/packages/server/src/tests/utilities/api/index.ts +++ b/packages/server/src/tests/utilities/api/index.ts @@ -9,6 +9,7 @@ import { ScreenAPI } from "./screen" import { ApplicationAPI } from "./application" import { BackupAPI } from "./backup" import { AttachmentAPI } from "./attachment" +import { UserAPI } from "./user" export default class API { table: TableAPI @@ -21,6 +22,7 @@ export default class API { application: ApplicationAPI backup: BackupAPI attachment: AttachmentAPI + user: UserAPI constructor(config: TestConfiguration) { this.table = new TableAPI(config) @@ -33,5 +35,6 @@ export default class API { this.application = new ApplicationAPI(config) this.backup = new BackupAPI(config) this.attachment = new AttachmentAPI(config) + this.user = new UserAPI(config) } } diff --git a/packages/server/src/tests/utilities/api/user.ts b/packages/server/src/tests/utilities/api/user.ts new file mode 100644 index 0000000000..2ed23c0461 --- /dev/null +++ b/packages/server/src/tests/utilities/api/user.ts @@ -0,0 +1,157 @@ +import { + FetchUserMetadataResponse, + FindUserMetadataResponse, + Flags, + UserMetadata, +} from "@budibase/types" +import TestConfiguration from "../TestConfiguration" +import { TestAPI } from "./base" +import { DocumentInsertResponse } from "@budibase/nano" + +export class UserAPI extends TestAPI { + constructor(config: TestConfiguration) { + super(config) + } + + fetch = async ( + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const res = await this.request + .get(`/api/users/metadata`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + + return res.body + } + + find = async ( + id: string, + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const res = await this.request + .get(`/api/users/metadata/${id}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + + return res.body + } + + update = async ( + user: UserMetadata, + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const res = await this.request + .put(`/api/users/metadata`) + .set(this.config.defaultHeaders()) + .send(user) + .expect("Content-Type", /json/) + + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + + return res.body as DocumentInsertResponse + } + + updateSelf = async ( + user: UserMetadata, + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const res = await this.request + .post(`/api/users/metadata/self`) + .set(this.config.defaultHeaders()) + .send(user) + .expect("Content-Type", /json/) + + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + + return res.body as DocumentInsertResponse + } + + destroy = async ( + id: string, + { expectStatus } = { expectStatus: 200 } + ): Promise<{ message: string }> => { + const res = await this.request + .delete(`/api/users/metadata/${id}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + + return res.body as { message: string } + } + + setFlag = async ( + flag: string, + value: any, + { expectStatus } = { expectStatus: 200 } + ): Promise<{ message: string }> => { + const res = await this.request + .post(`/api/users/flags`) + .set(this.config.defaultHeaders()) + .send({ flag, value }) + .expect("Content-Type", /json/) + + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + + return res.body as { message: string } + } + + getFlags = async ( + { expectStatus } = { expectStatus: 200 } + ): Promise => { + const res = await this.request + .get(`/api/users/flags`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + + return res.body as Flags + } +} diff --git a/packages/server/src/utilities/users.ts b/packages/server/src/utilities/users.ts index bbc1370355..73b2f48b15 100644 --- a/packages/server/src/utilities/users.ts +++ b/packages/server/src/utilities/users.ts @@ -1,11 +1,13 @@ import { InternalTables } from "../db/utils" import { getGlobalUser } from "./global" import { context, roles } from "@budibase/backend-core" -import { UserCtx } from "@budibase/types" +import { ContextUserMetadata, UserCtx, UserMetadata } from "@budibase/types" -export async function getFullUser(ctx: UserCtx, userId: string) { +export async function getFullUser( + userId: string +): Promise { const global = await getGlobalUser(userId) - let metadata: any = {} + let metadata: UserMetadata | undefined = undefined // always prefer the user metadata _id and _rev delete global._id @@ -14,11 +16,11 @@ export async function getFullUser(ctx: UserCtx, userId: string) { try { // this will throw an error if the db doesn't exist, or there is no appId const db = context.getAppDB() - metadata = await db.get(userId) + metadata = await db.get(userId) + delete metadata.csrfToken } catch (err) { // it is fine if there is no user metadata yet } - delete metadata.csrfToken return { ...metadata, ...global, diff --git a/packages/types/src/api/web/app/index.ts b/packages/types/src/api/web/app/index.ts index f5b876009b..cb1cea2b08 100644 --- a/packages/types/src/api/web/app/index.ts +++ b/packages/types/src/api/web/app/index.ts @@ -6,3 +6,4 @@ export * from "./rows" export * from "./table" export * from "./permission" export * from "./attachment" +export * from "./user" diff --git a/packages/types/src/api/web/app/user.ts b/packages/types/src/api/web/app/user.ts new file mode 100644 index 0000000000..7faec83e9c --- /dev/null +++ b/packages/types/src/api/web/app/user.ts @@ -0,0 +1,9 @@ +import { ContextUserMetadata } from "../../../" + +export type FetchUserMetadataResponse = ContextUserMetadata[] +export type FindUserMetadataResponse = ContextUserMetadata + +export interface SetFlagRequest { + flag: string + value: any +} diff --git a/packages/types/src/documents/account/flag.ts b/packages/types/src/documents/account/flag.ts new file mode 100644 index 0000000000..a214348fe7 --- /dev/null +++ b/packages/types/src/documents/account/flag.ts @@ -0,0 +1,5 @@ +import { Document } from "../../" + +export interface Flags extends Document { + [key: string]: any +} diff --git a/packages/types/src/documents/account/index.ts b/packages/types/src/documents/account/index.ts index 663fb91b58..1e0c800f39 100644 --- a/packages/types/src/documents/account/index.ts +++ b/packages/types/src/documents/account/index.ts @@ -1,2 +1,3 @@ export * from "./account" export * from "./user" +export * from "./flag"