budibase/packages/server/src/api/routes/tests/rowAction.spec.ts

834 lines
23 KiB
TypeScript

import _ from "lodash"
import tk from "timekeeper"
import {
CreateRowActionRequest,
DocumentType,
PermissionLevel,
Row,
RowActionResponse,
} from "@budibase/types"
import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests"
import { Expectations } from "../../../tests/utilities/api/base"
import { roles } from "@budibase/backend-core"
import { automations } from "@budibase/pro"
const expectAutomationId = () =>
expect.stringMatching(`^${DocumentType.AUTOMATION}_.+`)
describe("/rowsActions", () => {
const config = setup.getConfig()
let tableId: string
beforeAll(async () => {
tk.freeze(new Date())
await config.init()
})
beforeEach(async () => {
const table = await config.api.table.save(setup.structures.basicTable())
tableId = table._id!
})
afterAll(setup.afterAll)
const createRowAction = config.api.rowAction.save
function createRowActionRequest(): CreateRowActionRequest {
return {
name: generator.string(),
}
}
function createRowActionRequests(count: number): CreateRowActionRequest[] {
return generator
.unique(() => generator.string(), count)
.map(name => ({ name }))
}
function unauthorisedTests(
apiDelegate: (
expectations: Expectations,
testConfig?: { publicUser?: boolean }
) => Promise<any>
) {
it("returns unauthorised (401) for unauthenticated requests", async () => {
await apiDelegate(
{
status: 401,
body: {
message: "Session not authenticated",
},
},
{ publicUser: true }
)
})
it("returns forbidden (403) for non-builder users", async () => {
const user = await config.createUser({
builder: {},
})
await config.withUser(user, async () => {
await createRowAction(generator.guid(), createRowActionRequest(), {
status: 403,
body: {
message: "Not Authorized",
},
})
})
})
it("returns forbidden (403) for non-builder users even if they have table write permissions", async () => {
const user = await config.createUser({
builder: {},
})
const tableId = generator.guid()
for (const role of Object.values(roles.BUILTIN_ROLE_IDS)) {
await config.api.permission.add({
roleId: role,
resourceId: tableId,
level: PermissionLevel.EXECUTE,
})
}
// replicate changes before checking permissions
await config.publish()
await config.withUser(user, async () => {
await createRowAction(tableId, createRowActionRequest(), {
status: 403,
body: {
message: "Not Authorized",
},
})
})
})
it("rejects (404) for a non-existing table", async () => {
await createRowAction(generator.guid(), createRowActionRequest(), {
status: 404,
})
})
}
describe("create", () => {
unauthorisedTests((expectations, testConfig) =>
createRowAction(
tableId,
createRowActionRequest(),
expectations,
testConfig
)
)
it("creates new row actions for tables without existing actions", async () => {
const rowAction = createRowActionRequest()
const res = await createRowAction(tableId, rowAction, {
status: 201,
})
expect(res).toEqual({
name: rowAction.name,
id: expect.stringMatching(/^row_action_\w+/),
tableId: tableId,
automationId: expectAutomationId(),
})
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: {
name: rowAction.name,
id: res.id,
tableId: tableId,
automationId: expectAutomationId(),
},
},
})
})
it("trims row action names", async () => {
const name = " action name "
const res = await createRowAction(tableId, { name })
expect(res).toEqual(
expect.objectContaining({
name: "action name",
})
)
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: expect.objectContaining({
name: "action name",
}),
},
})
})
it("can create multiple row actions for the same table", async () => {
const rowActions = createRowActionRequests(3)
const responses: RowActionResponse[] = []
for (const action of rowActions) {
responses.push(await createRowAction(tableId, action))
}
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[responses[0].id]: {
name: rowActions[0].name,
id: responses[0].id,
tableId,
automationId: expectAutomationId(),
},
[responses[1].id]: {
name: rowActions[1].name,
id: responses[1].id,
tableId,
automationId: expectAutomationId(),
},
[responses[2].id]: {
name: rowActions[2].name,
id: responses[2].id,
tableId,
automationId: expectAutomationId(),
},
},
})
})
it("rejects with bad request when creating with no name", async () => {
const rowAction: CreateRowActionRequest = {
name: "",
}
await createRowAction(tableId, rowAction, {
status: 400,
body: {
message: 'Invalid body - "name" is not allowed to be empty',
},
})
})
it("ignores not valid row action data", async () => {
const rowAction = createRowActionRequest()
const dirtyRowAction = {
name: rowAction.name,
id: generator.guid(),
valueToIgnore: generator.string(),
}
const res = await createRowAction(tableId, dirtyRowAction)
expect(res).toEqual({
name: rowAction.name,
id: expect.any(String),
tableId,
automationId: expectAutomationId(),
})
expect(await config.api.rowAction.find(tableId)).toEqual({
actions: {
[res.id]: {
name: rowAction.name,
id: res.id,
tableId: tableId,
automationId: expectAutomationId(),
},
},
})
})
it("can not create multiple row actions with the same name (for the same table)", async () => {
const action = await createRowAction(tableId, {
name: "Row action name ",
})
await createRowAction(
tableId,
{ name: action.name },
{
status: 409,
body: {
message: "A row action with the same name already exists.",
},
}
)
await createRowAction(
tableId,
{ name: "row action name" },
{
status: 409,
body: {
message: "A row action with the same name already exists.",
},
}
)
})
it("can reuse row action names between different tables", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
const action = await createRowAction(tableId, createRowActionRequest())
await createRowAction(otherTable._id!, { name: action.name })
})
it("an automation is created when creating a new row action", async () => {
const action1 = await createRowAction(tableId, createRowActionRequest())
const action2 = await createRowAction(tableId, createRowActionRequest())
for (const automationId of [action1.automationId, action2.automationId]) {
expect(await config.api.automation.get(automationId)).toEqual(
expect.objectContaining({ _id: automationId })
)
}
})
})
describe("find", () => {
unauthorisedTests((expectations, testConfig) =>
config.api.rowAction.find(tableId, expectations, testConfig)
)
it("returns only the actions for the requested table", async () => {
const rowActions: RowActionResponse[] = []
for (const action of createRowActionRequests(3)) {
rowActions.push(await createRowAction(tableId, action))
}
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const response = await config.api.rowAction.find(tableId)
expect(response).toEqual({
actions: {
[rowActions[0].id]: expect.any(Object),
[rowActions[1].id]: expect.any(Object),
[rowActions[2].id]: expect.any(Object),
},
})
})
it("returns empty for tables without row actions", async () => {
const response = await config.api.rowAction.find(tableId)
expect(response).toEqual({
actions: {},
})
})
})
describe("update", () => {
unauthorisedTests((expectations, testConfig) =>
config.api.rowAction.update(
tableId,
generator.guid(),
createRowActionRequest(),
expectations,
testConfig
)
)
it("can update existing actions", async () => {
for (const rowAction of createRowActionRequests(3)) {
await createRowAction(tableId, rowAction)
}
const persisted = await config.api.rowAction.find(tableId)
const [actionId, actionData] = _.sample(
Object.entries(persisted.actions)
)!
const updatedName = generator.string()
const res = await config.api.rowAction.update(tableId, actionId, {
name: updatedName,
})
expect(res).toEqual({
id: actionId,
tableId,
name: updatedName,
automationId: actionData.automationId,
})
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: expect.objectContaining({
[actionId]: {
name: updatedName,
id: actionData.id,
tableId: actionData.tableId,
automationId: actionData.automationId,
},
}),
})
)
})
it("trims row action names", async () => {
const rowAction = await createRowAction(tableId, createRowActionRequest())
const res = await config.api.rowAction.update(tableId, rowAction.id, {
name: " action name ",
})
expect(res).toEqual(expect.objectContaining({ name: "action name" }))
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: expect.objectContaining({
[rowAction.id]: expect.objectContaining({
name: "action name",
}),
}),
})
)
})
it("throws Bad Request when trying to update by a non-existing id", async () => {
await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
tableId,
generator.guid(),
createRowActionRequest(),
{ status: 400 }
)
})
it("throws Bad Request when trying to update by a via another table id", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const action = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
otherTable._id!,
action.id,
createRowActionRequest(),
{ status: 400 }
)
})
it("can not use existing row action names (for the same table)", async () => {
const action1 = await createRowAction(tableId, createRowActionRequest())
const action2 = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(
tableId,
action1.id,
{ name: action2.name },
{
status: 409,
body: {
message: "A row action with the same name already exists.",
},
}
)
})
it("does not throw with name conflicts for the same row action", async () => {
const action1 = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.update(tableId, action1.id, {
name: action1.name,
})
})
})
describe("delete", () => {
unauthorisedTests((expectations, testConfig) =>
config.api.rowAction.delete(
tableId,
generator.guid(),
expectations,
testConfig
)
)
it("can delete existing actions", async () => {
const actions: RowActionResponse[] = []
for (const rowAction of createRowActionRequests(3)) {
actions.push(await createRowAction(tableId, rowAction))
}
const actionToDelete = _.sample(actions)!
await config.api.rowAction.delete(tableId, actionToDelete.id, {
status: 204,
})
expect(await config.api.rowAction.find(tableId)).toEqual(
expect.objectContaining({
actions: actions
.filter(a => a.id !== actionToDelete.id)
.reduce((acc, c) => ({ ...acc, [c.id]: expect.any(Object) }), {}),
})
)
})
it("throws Bad Request when trying to delete by a non-existing id", async () => {
await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.delete(tableId, generator.guid(), {
status: 400,
})
})
it("throws Bad Request when trying to delete by a via another table id", async () => {
const otherTable = await config.api.table.save(
setup.structures.basicTable()
)
await createRowAction(otherTable._id!, createRowActionRequest())
const action = await createRowAction(tableId, createRowActionRequest())
await config.api.rowAction.delete(otherTable._id!, action.id, {
status: 400,
})
})
it("deletes the linked automation", async () => {
const actions: RowActionResponse[] = []
for (const rowAction of createRowActionRequests(3)) {
actions.push(await createRowAction(tableId, rowAction))
}
const actionToDelete = _.sample(actions)!
await config.api.rowAction.delete(tableId, actionToDelete.id, {
status: 204,
})
await config.api.automation.get(actionToDelete.automationId, {
status: 404,
})
for (const action of actions.filter(a => a.id !== actionToDelete.id)) {
await config.api.automation.get(action.automationId, {
status: 200,
})
}
})
})
describe("set/unsetViewPermission", () => {
describe.each([
["setViewPermission", config.api.rowAction.setViewPermission],
["unsetViewPermission", config.api.rowAction.unsetViewPermission],
])("unauthorisedTests for %s", (__, delegateTest) => {
unauthorisedTests((expectations, testConfig) =>
delegateTest(
tableId,
generator.guid(),
generator.guid(),
expectations,
testConfig
)
)
})
let tableIdForDescribe: string
let actionId1: string, actionId2: string
let viewId1: string, viewId2: string
beforeAll(async () => {
tableIdForDescribe = tableId
for (const rowAction of createRowActionRequests(3)) {
await createRowAction(tableId, rowAction)
}
const persisted = await config.api.rowAction.find(tableId)
const actions = _.sampleSize(Object.keys(persisted.actions), 2)
actionId1 = actions[0]
actionId2 = actions[1]
viewId1 = (
await config.api.viewV2.create(
setup.structures.viewV2.createRequest(tableId)
)
).id
viewId2 = (
await config.api.viewV2.create(
setup.structures.viewV2.createRequest(tableId)
)
).id
})
beforeEach(() => {
// Hack to reuse tables for these given tests
tableId = tableIdForDescribe
})
it("can set permission views", async () => {
await config.api.rowAction.setViewPermission(tableId, viewId1, actionId1)
const action1Result = await config.api.rowAction.setViewPermission(
tableId,
viewId2,
actionId1
)
const action2Result = await config.api.rowAction.setViewPermission(
tableId,
viewId1,
actionId2
)
const expectedAction1 = expect.objectContaining({
allowedViews: [viewId1, viewId2],
})
const expectedAction2 = expect.objectContaining({
allowedViews: [viewId1],
})
const expectedActions = expect.objectContaining({
[actionId1]: expectedAction1,
[actionId2]: expectedAction2,
})
expect(action1Result).toEqual(expectedAction1)
expect(action2Result).toEqual(expectedAction2)
expect((await config.api.rowAction.find(tableId)).actions).toEqual(
expectedActions
)
})
it("can unset permission views", async () => {
const actionResult = await config.api.rowAction.unsetViewPermission(
tableId,
viewId1,
actionId1
)
const expectedAction = expect.objectContaining({
allowedViews: [viewId2],
})
expect(actionResult).toEqual(expectedAction)
expect(
(await config.api.rowAction.find(tableId)).actions[actionId1]
).toEqual(expectedAction)
})
it.each([
["setViewPermission", config.api.rowAction.setViewPermission],
["unsetViewPermission", config.api.rowAction.unsetViewPermission],
])(
"cannot update permission views for unexisting views (%s)",
async (__, delegateTest) => {
const viewId = generator.guid()
await delegateTest(tableId, viewId, actionId1, {
status: 400,
body: {
message: `View '${viewId}' not found in '${tableId}'`,
},
})
}
)
it.each([
["setViewPermission", config.api.rowAction.setViewPermission],
["unsetViewPermission", config.api.rowAction.unsetViewPermission],
])(
"cannot update permission views crossing table views (%s)",
async (__, delegateTest) => {
const anotherTable = await config.api.table.save(
setup.structures.basicTable()
)
const { id: viewId } = await config.api.viewV2.create(
setup.structures.viewV2.createRequest(anotherTable._id!)
)
await delegateTest(tableId, viewId, actionId1, {
status: 400,
body: {
message: `View '${viewId}' not found in '${tableId}'`,
},
})
}
)
})
describe("trigger", () => {
let row: Row
let rowAction: RowActionResponse
beforeEach(async () => {
row = await config.api.row.save(tableId, {})
rowAction = await createRowAction(tableId, createRowActionRequest())
await config.publish()
tk.travel(Date.now() + 100)
})
async function getAutomationLogs() {
const { data: automationLogs } = await config.doInContext(
config.getProdAppId(),
async () =>
automations.logs.logSearch({ startDate: new Date().toISOString() })
)
return automationLogs
}
it("can trigger an automation given valid data", async () => {
await config.api.rowAction.trigger(tableId, rowAction.id, {
rowId: row._id!,
})
const automationLogs = await getAutomationLogs()
expect(automationLogs).toEqual([
expect.objectContaining({
automationId: rowAction.automationId,
trigger: expect.objectContaining({
outputs: {
fields: {},
row: await config.api.row.get(tableId, row._id!),
table: await config.api.table.get(tableId),
},
}),
}),
])
})
it("rejects triggering from a non-allowed view", async () => {
const viewId = (
await config.api.viewV2.create(
setup.structures.viewV2.createRequest(tableId)
)
).id
await config.publish()
await config.api.rowAction.trigger(
viewId,
rowAction.id,
{
rowId: row._id!,
},
{
status: 403,
body: {
message: `Row action '${rowAction.id}' is not enabled for view '${viewId}'`,
},
}
)
const automationLogs = await getAutomationLogs()
expect(automationLogs).toEqual([])
})
it("triggers from an allowed view", async () => {
const viewId = (
await config.api.viewV2.create(
setup.structures.viewV2.createRequest(tableId)
)
).id
await config.api.rowAction.setViewPermission(
tableId,
viewId,
rowAction.id
)
await config.publish()
await config.api.rowAction.trigger(viewId, rowAction.id, {
rowId: row._id!,
})
const automationLogs = await getAutomationLogs()
expect(automationLogs).toEqual([
expect.objectContaining({
automationId: rowAction.automationId,
}),
])
})
describe("role permission checks", () => {
function createUser(role: string) {
return config.createUser({
admin: { global: false },
builder: {},
roles: { [config.getProdAppId()]: role },
})
}
function getRolesHigherThan(role: string) {
const result = Object.values(roles.BUILTIN_ROLE_IDS).filter(
r => r !== role && roles.lowerBuiltinRoleID(r, role) === role
)
return result
}
function getRolesLowerThan(role: string) {
const result = Object.values(roles.BUILTIN_ROLE_IDS).filter(
r => r !== role && roles.lowerBuiltinRoleID(r, role) === r
)
return result
}
const allowedRoleConfig = Object.values(roles.BUILTIN_ROLE_IDS).flatMap(
r => [r, ...getRolesLowerThan(r)].map(p => [r, p])
)
const disallowedRoleConfig = Object.values(
roles.BUILTIN_ROLE_IDS
).flatMap(r => getRolesHigherThan(r).map(p => [r, p]))
it.each(allowedRoleConfig)(
"allows triggering if the user has table read permission (user %s, table %s)",
async (userRole, resourcePermission) => {
await config.api.permission.add({
level: PermissionLevel.READ,
resourceId: tableId,
roleId: resourcePermission,
})
const normalUser = await createUser(userRole)
await config.withUser(normalUser, async () => {
await config.publish()
await config.api.rowAction.trigger(
tableId,
rowAction.id,
{
rowId: row._id!,
},
{ status: 200 }
)
})
}
)
it.each(disallowedRoleConfig)(
"rejects if the user does not have table read permission (user %s, table %s)",
async (userRole, resourcePermission) => {
await config.api.permission.add({
level: PermissionLevel.READ,
resourceId: tableId,
roleId: resourcePermission,
})
const normalUser = await createUser(userRole)
await config.withUser(normalUser, async () => {
await config.publish()
await config.api.rowAction.trigger(
tableId,
rowAction.id,
{
rowId: row._id!,
},
{
status: 403,
body: { message: "User does not have permission" },
}
)
const automationLogs = await getAutomationLogs()
expect(automationLogs).toBeEmpty()
})
}
)
})
})
})