Merge branch 'master' into BUDI-9077/type-bbui-multiselect
This commit is contained in:
commit
a1a0ef2a70
|
@ -1,18 +1,15 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||||
import { FieldType } from "@budibase/types"
|
import { FieldType, InternalTable } from "@budibase/types"
|
||||||
import { fetchData, Utils } from "@budibase/frontend-core"
|
import { fetchData, Utils } from "@budibase/frontend-core"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
import Field from "./Field.svelte"
|
import Field from "./Field.svelte"
|
||||||
import type {
|
import type {
|
||||||
SearchFilter,
|
SearchFilter,
|
||||||
RelationshipFieldMetadata,
|
RelationshipFieldMetadata,
|
||||||
Table,
|
|
||||||
Row,
|
Row,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
const { API } = getContext("sdk")
|
|
||||||
|
|
||||||
export let field: string | undefined = undefined
|
export let field: string | undefined = undefined
|
||||||
export let label: string | undefined = undefined
|
export let label: string | undefined = undefined
|
||||||
export let placeholder: any = undefined
|
export let placeholder: any = undefined
|
||||||
|
@ -20,10 +17,10 @@
|
||||||
export let readonly: boolean = false
|
export let readonly: boolean = false
|
||||||
export let validation: any
|
export let validation: any
|
||||||
export let autocomplete: boolean = true
|
export let autocomplete: boolean = true
|
||||||
export let defaultValue: string | undefined = undefined
|
export let defaultValue: string | string[] | undefined = undefined
|
||||||
export let onChange: any
|
export let onChange: any
|
||||||
export let filter: SearchFilter[]
|
export let filter: SearchFilter[]
|
||||||
export let datasourceType: "table" | "user" | "groupUser" = "table"
|
export let datasourceType: "table" | "user" = "table"
|
||||||
export let primaryDisplay: string | undefined = undefined
|
export let primaryDisplay: string | undefined = undefined
|
||||||
export let span: number | undefined = undefined
|
export let span: number | undefined = undefined
|
||||||
export let helpText: string | undefined = undefined
|
export let helpText: string | undefined = undefined
|
||||||
|
@ -32,191 +29,305 @@
|
||||||
| FieldType.BB_REFERENCE
|
| FieldType.BB_REFERENCE
|
||||||
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
|
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
|
||||||
|
|
||||||
type RelationshipValue = { _id: string; [key: string]: any }
|
type BasicRelatedRow = { _id: string; primaryDisplay: string }
|
||||||
type OptionObj = Record<string, RelationshipValue>
|
type OptionsMap = Record<string, BasicRelatedRow>
|
||||||
type OptionsObjType = Record<string, OptionObj>
|
|
||||||
|
|
||||||
|
const { API } = getContext("sdk")
|
||||||
|
|
||||||
|
// Field state
|
||||||
let fieldState: any
|
let fieldState: any
|
||||||
let fieldApi: any
|
let fieldApi: any
|
||||||
let fieldSchema: RelationshipFieldMetadata | undefined
|
let fieldSchema: RelationshipFieldMetadata | undefined
|
||||||
let tableDefinition: Table | null | undefined
|
|
||||||
let searchTerm: any
|
|
||||||
let open: boolean
|
|
||||||
let selectedValue: string[] | string
|
|
||||||
|
|
||||||
// need a cast version of this for reactivity, components below aren't typed
|
// Local UI state
|
||||||
$: castSelectedValue = selectedValue as any
|
let searchTerm: any
|
||||||
|
let open: boolean = false
|
||||||
|
|
||||||
|
// Options state
|
||||||
|
let options: BasicRelatedRow[] = []
|
||||||
|
let optionsMap: OptionsMap = {}
|
||||||
|
let loadingMissingOptions: boolean = false
|
||||||
|
|
||||||
|
// Determine if we can select multiple rows or not
|
||||||
$: multiselect =
|
$: multiselect =
|
||||||
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
|
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
|
||||||
fieldSchema?.relationshipType !== "one-to-many"
|
fieldSchema?.relationshipType !== "one-to-many"
|
||||||
$: linkedTableId = fieldSchema?.tableId!
|
|
||||||
$: fetch = fetchData({
|
// Get the proper string representation of the value
|
||||||
|
$: realValue = fieldState?.value
|
||||||
|
$: selectedValue = parseSelectedValue(realValue, multiselect)
|
||||||
|
$: selectedIDs = getSelectedIDs(selectedValue)
|
||||||
|
|
||||||
|
// If writable, we use a fetch to load options
|
||||||
|
$: linkedTableId = fieldSchema?.tableId
|
||||||
|
$: writable = !disabled && !readonly
|
||||||
|
$: fetch = createFetch(writable, datasourceType, filter, linkedTableId)
|
||||||
|
|
||||||
|
// Attempt to determine the primary display field to use
|
||||||
|
$: tableDefinition = $fetch?.definition
|
||||||
|
$: primaryDisplayField = primaryDisplay || tableDefinition?.primaryDisplay
|
||||||
|
|
||||||
|
// Build our options map
|
||||||
|
$: rows = $fetch?.rows || []
|
||||||
|
$: processOptions(realValue, rows, primaryDisplayField)
|
||||||
|
|
||||||
|
// If we ever have a value selected for which we don't have an option, we must
|
||||||
|
// fetch those rows to ensure we can render them as options
|
||||||
|
$: missingIDs = selectedIDs.filter(id => !optionsMap[id])
|
||||||
|
$: loadMissingOptions(missingIDs, linkedTableId, primaryDisplayField)
|
||||||
|
|
||||||
|
// Convert our options map into an array for display
|
||||||
|
$: updateOptions(optionsMap)
|
||||||
|
$: !open && sortOptions()
|
||||||
|
|
||||||
|
// Search for new options when search term changes
|
||||||
|
$: debouncedSearchOptions(searchTerm || "", primaryDisplayField)
|
||||||
|
|
||||||
|
// Ensure backwards compatibility
|
||||||
|
$: enrichedDefaultValue = enrichDefaultValue(defaultValue)
|
||||||
|
|
||||||
|
// We need to cast value to pass it down, as those components aren't typed
|
||||||
|
$: emptyValue = multiselect ? [] : null
|
||||||
|
$: displayValue = missingIDs.length ? emptyValue : (selectedValue as any)
|
||||||
|
|
||||||
|
// Ensures that we flatten any objects so that only the IDs of the selected
|
||||||
|
// rows are passed down. Not sure how this can be an object to begin with?
|
||||||
|
const parseSelectedValue = (
|
||||||
|
value: any,
|
||||||
|
multiselect: boolean
|
||||||
|
): undefined | string | string[] => {
|
||||||
|
return multiselect ? flatten(value) : flatten(value)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where applicable, creates the fetch instance to load row options
|
||||||
|
const createFetch = (
|
||||||
|
writable: boolean,
|
||||||
|
dsType: typeof datasourceType,
|
||||||
|
filter: SearchFilter[],
|
||||||
|
linkedTableId?: string
|
||||||
|
) => {
|
||||||
|
if (!linkedTableId) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const datasource =
|
||||||
|
datasourceType === "table"
|
||||||
|
? {
|
||||||
|
type: datasourceType,
|
||||||
|
tableId: fieldSchema?.tableId!,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: datasourceType,
|
||||||
|
tableId: InternalTable.USER_METADATA,
|
||||||
|
}
|
||||||
|
return fetchData({
|
||||||
API,
|
API,
|
||||||
datasource: {
|
datasource,
|
||||||
// typing here doesn't seem correct - we have the correct datasourceType options
|
|
||||||
// but when we configure the fetchData, it seems to think only "table" is valid
|
|
||||||
type: datasourceType as any,
|
|
||||||
tableId: linkedTableId,
|
|
||||||
},
|
|
||||||
options: {
|
options: {
|
||||||
filter,
|
filter,
|
||||||
limit: 100,
|
limit: writable ? 100 : 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
$: tableDefinition = $fetch.definition
|
|
||||||
$: selectedValue = multiselect
|
|
||||||
? flatten(fieldState?.value) ?? []
|
|
||||||
: flatten(fieldState?.value)?.[0]
|
|
||||||
$: component = multiselect ? CoreMultiselect : CoreSelect
|
|
||||||
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
|
|
||||||
|
|
||||||
let optionsObj: OptionsObjType = {}
|
|
||||||
const debouncedFetchRows = Utils.debounce(fetchRows, 250)
|
|
||||||
|
|
||||||
$: {
|
|
||||||
if (primaryDisplay && fieldState && !optionsObj) {
|
|
||||||
// Persist the initial values as options, allowing them to be present in the dropdown,
|
|
||||||
// even if they are not in the inital fetch results
|
|
||||||
let valueAsSafeArray = fieldState.value || []
|
|
||||||
if (!Array.isArray(valueAsSafeArray)) {
|
|
||||||
valueAsSafeArray = [fieldState.value]
|
|
||||||
}
|
}
|
||||||
optionsObj = valueAsSafeArray.reduce(
|
|
||||||
(
|
// Small helper to represent the selected value as an array
|
||||||
accumulator: OptionObj,
|
const getSelectedIDs = (
|
||||||
value: { _id: string; primaryDisplay: any }
|
selectedValue: undefined | string | string[]
|
||||||
|
): string[] => {
|
||||||
|
if (!selectedValue) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return Array.isArray(selectedValue) ? selectedValue : [selectedValue]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a map of all available options, in a consistent structure
|
||||||
|
const processOptions = (
|
||||||
|
realValue: any | any[],
|
||||||
|
rows: Row[],
|
||||||
|
primaryDisplay?: string
|
||||||
) => {
|
) => {
|
||||||
// fieldState has to be an array of strings to be valid for an update
|
// First ensure that all options included in the value are present as valid
|
||||||
// therefore we cannot guarantee value will be an object
|
// options. These can be basic related row shapes which already include
|
||||||
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
|
// a value for primary display
|
||||||
if (!value._id) {
|
if (realValue) {
|
||||||
return accumulator
|
const valueArray = Array.isArray(realValue) ? realValue : [realValue]
|
||||||
|
for (let val of valueArray) {
|
||||||
|
const option = parseOption(val, primaryDisplay)
|
||||||
|
if (option) {
|
||||||
|
optionsMap[option._id] = option
|
||||||
}
|
}
|
||||||
accumulator[value._id] = {
|
|
||||||
_id: value._id,
|
|
||||||
[primaryDisplay]: value.primaryDisplay,
|
|
||||||
}
|
|
||||||
return accumulator
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: enrichedOptions = enrichOptions(optionsObj, $fetch.rows)
|
// Process all rows loaded from our fetch
|
||||||
const enrichOptions = (optionsObj: OptionsObjType, fetchResults: Row[]) => {
|
for (let row of rows) {
|
||||||
const result = (fetchResults || [])?.reduce((accumulator, row) => {
|
const option = parseOption(row, primaryDisplay)
|
||||||
if (!accumulator[row._id!]) {
|
if (option) {
|
||||||
accumulator[row._id!] = row
|
optionsMap[option._id] = option
|
||||||
}
|
|
||||||
return accumulator
|
|
||||||
}, optionsObj || {})
|
|
||||||
|
|
||||||
return Object.values(result)
|
|
||||||
}
|
|
||||||
$: {
|
|
||||||
// We don't want to reorder while the dropdown is open, to avoid UX jumps
|
|
||||||
if (!open && primaryDisplay) {
|
|
||||||
enrichedOptions = enrichedOptions.sort((a: OptionObj, b: OptionObj) => {
|
|
||||||
const selectedValues = flatten(fieldState?.value) || []
|
|
||||||
|
|
||||||
const aIsSelected = selectedValues.find(
|
|
||||||
(v: RelationshipValue) => v === a._id
|
|
||||||
)
|
|
||||||
const bIsSelected = selectedValues.find(
|
|
||||||
(v: RelationshipValue) => v === b._id
|
|
||||||
)
|
|
||||||
if (aIsSelected && !bIsSelected) {
|
|
||||||
return -1
|
|
||||||
} else if (!aIsSelected && bIsSelected) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return (a[primaryDisplay] > b[primaryDisplay]) as unknown as number
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
// Reassign to trigger reactivity
|
||||||
if (filter || defaultValue) {
|
optionsMap = optionsMap
|
||||||
forceFetchRows()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
|
||||||
|
|
||||||
const forceFetchRows = async () => {
|
// Parses a row-like structure into a properly shaped option
|
||||||
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
|
const parseOption = (
|
||||||
optionsObj = {}
|
option: any | BasicRelatedRow | Row,
|
||||||
fieldApi?.setValue([])
|
primaryDisplay?: string
|
||||||
selectedValue = []
|
): BasicRelatedRow | null => {
|
||||||
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
if (!option || typeof option !== "object" || !option?._id) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
async function fetchRows(
|
// If this is a basic related row shape (_id and PD only) then just use
|
||||||
searchTerm: any,
|
// that
|
||||||
primaryDisplay: string,
|
if (Object.keys(option).length === 2 && "primaryDisplay" in option) {
|
||||||
defaultVal: string | string[]
|
return {
|
||||||
|
_id: option._id,
|
||||||
|
primaryDisplay: ensureString(option.primaryDisplay),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Otherwise use the primary display field specified
|
||||||
|
if (primaryDisplay) {
|
||||||
|
return {
|
||||||
|
_id: option._id,
|
||||||
|
primaryDisplay: ensureString(
|
||||||
|
option[primaryDisplay as keyof typeof option]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
_id: option._id,
|
||||||
|
primaryDisplay: option._id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads any rows which are selected and aren't present in the currently
|
||||||
|
// available option set. This is typically only IDs specified as default
|
||||||
|
// values.
|
||||||
|
const loadMissingOptions = async (
|
||||||
|
missingIDs: string[],
|
||||||
|
linkedTableId?: string,
|
||||||
|
primaryDisplay?: string
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
loadingMissingOptions ||
|
||||||
|
!missingIDs.length ||
|
||||||
|
!linkedTableId ||
|
||||||
|
!primaryDisplay
|
||||||
) {
|
) {
|
||||||
const allRowsFetched =
|
|
||||||
$fetch.loaded &&
|
|
||||||
!Object.keys($fetch.query?.string || {}).length &&
|
|
||||||
!$fetch.hasNextPage
|
|
||||||
// Don't request until we have the primary display or default value has been fetched
|
|
||||||
if (allRowsFetched || !primaryDisplay) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// must be an array
|
loadingMissingOptions = true
|
||||||
const defaultValArray: string[] = !defaultVal
|
try {
|
||||||
? []
|
const res = await API.searchTable(linkedTableId, {
|
||||||
: !Array.isArray(defaultVal)
|
query: {
|
||||||
? defaultVal.split(",")
|
oneOf: {
|
||||||
: defaultVal
|
_id: missingIDs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (let row of res.rows) {
|
||||||
|
const option = parseOption(row, primaryDisplay)
|
||||||
|
if (option) {
|
||||||
|
optionsMap[option._id] = option
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
// Reassign to trigger reactivity
|
||||||
defaultVal &&
|
optionsMap = optionsMap
|
||||||
optionsObj &&
|
updateOptions(optionsMap)
|
||||||
defaultValArray.some(val => !optionsObj[val])
|
} catch (error) {
|
||||||
) {
|
console.error("Error loading missing row IDs", error)
|
||||||
await fetch.update({
|
} finally {
|
||||||
query: { oneOf: { _id: defaultValArray } },
|
// Ensure we have some sort of option for all IDs
|
||||||
|
for (let id of missingIDs) {
|
||||||
|
if (!optionsMap[id]) {
|
||||||
|
optionsMap[id] = {
|
||||||
|
_id: id,
|
||||||
|
primaryDisplay: id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadingMissingOptions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates the options list to reflect the currently available options
|
||||||
|
const updateOptions = (optionsMap: OptionsMap) => {
|
||||||
|
let newOptions = Object.values(optionsMap)
|
||||||
|
|
||||||
|
// Only override options if the quantity of options changes
|
||||||
|
if (newOptions.length !== options.length) {
|
||||||
|
options = newOptions
|
||||||
|
sortOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorts the options list by selected state, then by primary display
|
||||||
|
const sortOptions = () => {
|
||||||
|
// Create a quick lookup map so we can test whether options are selected
|
||||||
|
const selectedMap: Record<string, boolean> = selectedIDs.reduce(
|
||||||
|
(map, id) => ({ ...map, [id]: true }),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
options.sort((a, b) => {
|
||||||
|
const aSelected = !!selectedMap[a._id]
|
||||||
|
const bSelected = !!selectedMap[b._id]
|
||||||
|
if (aSelected === bSelected) {
|
||||||
|
return a.primaryDisplay < b.primaryDisplay ? -1 : 1
|
||||||
|
} else {
|
||||||
|
return aSelected ? -1 : 1
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// Util to ensure a value is stringified
|
||||||
(Array.isArray(selectedValue) &&
|
const ensureString = (val: any): string => {
|
||||||
selectedValue.some(val => !optionsObj[val])) ||
|
return typeof val === "string" ? val : JSON.stringify(val)
|
||||||
(selectedValue && !optionsObj[selectedValue as string])
|
}
|
||||||
) {
|
|
||||||
await fetch.update({
|
// We previously included logic to manually process default value, which
|
||||||
query: {
|
// should not be done as it is handled by the core form logic.
|
||||||
oneOf: {
|
// This logic included handling a comma separated list of IDs, so for
|
||||||
_id: Array.isArray(selectedValue) ? selectedValue : [selectedValue],
|
// backwards compatibility we must now unfortunately continue to handle that
|
||||||
},
|
// at this level.
|
||||||
},
|
const enrichDefaultValue = (val: any) => {
|
||||||
})
|
if (!val || typeof val !== "string") {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return val.includes(",") ? val.split(",") : val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Searches for new options matching the given term
|
||||||
|
async function searchOptions(searchTerm: string, primaryDisplay?: string) {
|
||||||
|
if (!primaryDisplay) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we match all filters, rather than any
|
// Ensure we match all filters, rather than any
|
||||||
|
let newFilter: any = filter
|
||||||
|
if (searchTerm) {
|
||||||
// @ts-expect-error this doesn't fit types, but don't want to change it yet
|
// @ts-expect-error this doesn't fit types, but don't want to change it yet
|
||||||
const baseFilter: any = (filter || []).filter(x => x.operator !== "allOr")
|
newFilter = (newFilter || []).filter(x => x.operator !== "allOr")
|
||||||
await fetch.update({
|
newFilter.push({
|
||||||
filter: [
|
|
||||||
...baseFilter,
|
|
||||||
{
|
|
||||||
// Use a big numeric prefix to avoid clashing with an existing filter
|
// Use a big numeric prefix to avoid clashing with an existing filter
|
||||||
field: `999:${primaryDisplay}`,
|
field: `999:${primaryDisplay}`,
|
||||||
operator: "string",
|
operator: "string",
|
||||||
value: searchTerm,
|
value: searchTerm,
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await fetch?.update({
|
||||||
|
filter: newFilter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const debouncedSearchOptions = Utils.debounce(searchOptions, 250)
|
||||||
|
|
||||||
const flatten = (values: any | any[]) => {
|
// Flattens an array of row-like objects into a simple array of row IDs
|
||||||
|
const flatten = (values: any | any[]): string[] => {
|
||||||
if (!values) {
|
if (!values) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(values)) {
|
if (!Array.isArray(values)) {
|
||||||
values = [values]
|
values = [values]
|
||||||
}
|
}
|
||||||
|
@ -226,16 +337,11 @@
|
||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDisplayName = (row: Row) => {
|
|
||||||
return row?.[primaryDisplay!] || "-"
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChange = (e: any) => {
|
const handleChange = (e: any) => {
|
||||||
let value = e.detail
|
let value = e.detail
|
||||||
if (!multiselect) {
|
if (!multiselect) {
|
||||||
value = value == null ? [] : [value]
|
value = value == null ? [] : [value]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
type === FieldType.BB_REFERENCE_SINGLE &&
|
type === FieldType.BB_REFERENCE_SINGLE &&
|
||||||
value &&
|
value &&
|
||||||
|
@ -243,7 +349,6 @@
|
||||||
) {
|
) {
|
||||||
value = value[0] || null
|
value = value[0] || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const changed = fieldApi.setValue(value)
|
const changed = fieldApi.setValue(value)
|
||||||
if (onChange && changed) {
|
if (onChange && changed) {
|
||||||
onChange({
|
onChange({
|
||||||
|
@ -251,12 +356,6 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMore = () => {
|
|
||||||
if (!$fetch.loading) {
|
|
||||||
fetch.nextPage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
|
@ -265,31 +364,31 @@
|
||||||
{disabled}
|
{disabled}
|
||||||
{readonly}
|
{readonly}
|
||||||
{validation}
|
{validation}
|
||||||
{defaultValue}
|
|
||||||
{type}
|
{type}
|
||||||
{span}
|
{span}
|
||||||
{helpText}
|
{helpText}
|
||||||
|
defaultValue={enrichedDefaultValue}
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
bind:fieldApi
|
bind:fieldApi
|
||||||
bind:fieldSchema
|
bind:fieldSchema
|
||||||
>
|
>
|
||||||
{#if fieldState}
|
{#if fieldState}
|
||||||
<svelte:component
|
<svelte:component
|
||||||
this={component}
|
this={multiselect ? CoreMultiselect : CoreSelect}
|
||||||
options={enrichedOptions}
|
value={displayValue}
|
||||||
{autocomplete}
|
id={fieldState?.fieldId}
|
||||||
value={castSelectedValue}
|
disabled={fieldState?.disabled}
|
||||||
on:change={handleChange}
|
readonly={fieldState?.readonly}
|
||||||
on:loadMore={loadMore}
|
loading={!!$fetch?.loading}
|
||||||
id={fieldState.fieldId}
|
getOptionLabel={option => option.primaryDisplay}
|
||||||
disabled={fieldState.disabled}
|
|
||||||
readonly={fieldState.readonly}
|
|
||||||
getOptionLabel={getDisplayName}
|
|
||||||
getOptionValue={option => option._id}
|
getOptionValue={option => option._id}
|
||||||
|
{options}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
|
{autocomplete}
|
||||||
bind:searchTerm
|
bind:searchTerm
|
||||||
loading={$fetch.loading}
|
|
||||||
bind:open
|
bind:open
|
||||||
|
on:change={handleChange}
|
||||||
|
on:loadMore={() => fetch?.nextPage()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { GroupUserDatasource, InternalTable } from "@budibase/types"
|
||||||
|
|
||||||
interface GroupUserQuery {
|
interface GroupUserQuery {
|
||||||
groupId: string
|
groupId: string
|
||||||
emailSearch: string
|
emailSearch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroupUserDefinition {
|
interface GroupUserDefinition {
|
||||||
|
|
|
@ -9,8 +9,8 @@ import {
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
interface UserFetchQuery {
|
interface UserFetchQuery {
|
||||||
appId: string
|
appId?: string
|
||||||
paginated: boolean
|
paginated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserDefinition {
|
interface UserDefinition {
|
||||||
|
|
|
@ -156,6 +156,7 @@
|
||||||
"@types/pouchdb": "6.4.2",
|
"@types/pouchdb": "6.4.2",
|
||||||
"@types/server-destroy": "1.0.1",
|
"@types/server-destroy": "1.0.1",
|
||||||
"@types/supertest": "2.0.14",
|
"@types/supertest": "2.0.14",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/tar": "6.1.5",
|
"@types/tar": "6.1.5",
|
||||||
"@types/tmp": "0.2.6",
|
"@types/tmp": "0.2.6",
|
||||||
"@types/uuid": "8.3.4",
|
"@types/uuid": "8.3.4",
|
||||||
|
|
|
@ -4,11 +4,11 @@ import { examples, schemas } from "./resources"
|
||||||
import * as parameters from "./parameters"
|
import * as parameters from "./parameters"
|
||||||
import * as security from "./security"
|
import * as security from "./security"
|
||||||
|
|
||||||
const swaggerJsdoc = require("swagger-jsdoc")
|
import swaggerJsdoc from "swagger-jsdoc"
|
||||||
|
|
||||||
const VARIABLES = {}
|
const VARIABLES = {}
|
||||||
|
|
||||||
const options = {
|
const opts: swaggerJsdoc.Options = {
|
||||||
definition: {
|
definition: {
|
||||||
openapi: "3.0.0",
|
openapi: "3.0.0",
|
||||||
info: {
|
info: {
|
||||||
|
@ -58,7 +58,6 @@ const options = {
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeFile(output: any, filename: string) {
|
function writeFile(output: any, filename: string) {
|
||||||
try {
|
|
||||||
const path = join(__dirname, filename)
|
const path = join(__dirname, filename)
|
||||||
let spec = output
|
let spec = output
|
||||||
if (filename.endsWith("json")) {
|
if (filename.endsWith("json")) {
|
||||||
|
@ -71,17 +70,15 @@ function writeFile(output: any, filename: string) {
|
||||||
writeFileSync(path, spec)
|
writeFileSync(path, spec)
|
||||||
console.log(`Wrote spec to ${path}`)
|
console.log(`Wrote spec to ${path}`)
|
||||||
return path
|
return path
|
||||||
} catch (err) {
|
}
|
||||||
console.error("Error writing spec file", err)
|
|
||||||
}
|
export function spec() {
|
||||||
|
return swaggerJsdoc({ ...opts, format: ".json" })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function run() {
|
export function run() {
|
||||||
const outputJSON = swaggerJsdoc(options)
|
writeFile(swaggerJsdoc({ ...opts, format: ".json" }), "openapi.json")
|
||||||
options.format = ".yaml"
|
return writeFile(swaggerJsdoc({ ...opts, format: ".yaml" }), "openapi.yaml")
|
||||||
const outputYAML = swaggerJsdoc(options)
|
|
||||||
writeFile(outputJSON, "openapi.json")
|
|
||||||
return writeFile(outputYAML, "openapi.yaml")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
|
@ -9,6 +9,7 @@ import {
|
||||||
const DISABLED_EXTERNAL_INTEGRATIONS = [
|
const DISABLED_EXTERNAL_INTEGRATIONS = [
|
||||||
SourceName.AIRTABLE,
|
SourceName.AIRTABLE,
|
||||||
SourceName.BUDIBASE,
|
SourceName.BUDIBASE,
|
||||||
|
SourceName.ARANGODB,
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function fetch(ctx: UserCtx<void, FetchIntegrationsResponse>) {
|
export async function fetch(ctx: UserCtx<void, FetchIntegrationsResponse>) {
|
||||||
|
|
|
@ -48,7 +48,7 @@ function getUser(ctx: UserCtx, userId?: string) {
|
||||||
if (userId) {
|
if (userId) {
|
||||||
ctx.params = { userId }
|
ctx.params = { userId }
|
||||||
} else if (!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)
|
return readGlobalUser(ctx)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { paramResource, paramSubResource } from "../../../middleware/resourceId"
|
||||||
import { PermissionLevel, PermissionType } from "@budibase/types"
|
import { PermissionLevel, PermissionType } from "@budibase/types"
|
||||||
import { CtxFn } from "./utils/Endpoint"
|
import { CtxFn } from "./utils/Endpoint"
|
||||||
import mapperMiddleware from "./middleware/mapper"
|
import mapperMiddleware from "./middleware/mapper"
|
||||||
|
import testErrorHandling from "./middleware/testErrorHandling"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import { middleware, redis } from "@budibase/backend-core"
|
import { middleware, redis } from "@budibase/backend-core"
|
||||||
import { SelectableDatabase } from "@budibase/backend-core/src/redis/utils"
|
import { SelectableDatabase } from "@budibase/backend-core/src/redis/utils"
|
||||||
|
@ -144,6 +145,10 @@ function applyRoutes(
|
||||||
// add the output mapper middleware
|
// add the output mapper middleware
|
||||||
addMiddleware(endpoints.read, mapperMiddleware, { output: true })
|
addMiddleware(endpoints.read, mapperMiddleware, { output: true })
|
||||||
addMiddleware(endpoints.write, 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.read)
|
||||||
addToRouter(endpoints.write)
|
addToRouter(endpoints.write)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,24 +2,31 @@ import jestOpenAPI from "jest-openapi"
|
||||||
import { run as generateSchema } from "../../../../../specs/generate"
|
import { run as generateSchema } from "../../../../../specs/generate"
|
||||||
import * as setup from "../../tests/utilities"
|
import * as setup from "../../tests/utilities"
|
||||||
import { generateMakeRequest } from "./utils"
|
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()
|
const yamlPath = generateSchema()
|
||||||
jestOpenAPI(yamlPath!)
|
jestOpenAPI(yamlPath!)
|
||||||
|
|
||||||
let config = setup.getConfig()
|
describe("compare", () => {
|
||||||
let apiKey: string, table: Table, app: App, makeRequest: any
|
let config = setup.getConfig()
|
||||||
|
let apiKey: string, table: Table, app: App, makeRequest: any
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = await config.init()
|
app = await config.init()
|
||||||
table = await config.upsertTable()
|
table = await config.upsertTable()
|
||||||
apiKey = await config.generateApiKey()
|
apiKey = await config.generateApiKey()
|
||||||
makeRequest = generateMakeRequest(apiKey)
|
makeRequest = generateMakeRequest(apiKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(setup.afterAll)
|
afterAll(setup.afterAll)
|
||||||
|
|
||||||
describe("check the applications endpoints", () => {
|
beforeEach(() => {
|
||||||
|
nock.cleanAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("check the applications endpoints", () => {
|
||||||
it("should allow retrieving applications through search", async () => {
|
it("should allow retrieving applications through search", async () => {
|
||||||
const res = await makeRequest("post", "/applications/search")
|
const res = await makeRequest("post", "/applications/search")
|
||||||
expect(res).toSatisfyApiSpec()
|
expect(res).toSatisfyApiSpec()
|
||||||
|
@ -58,15 +65,19 @@ describe("check the applications endpoints", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should allow deleting an application", async () => {
|
it("should allow deleting an application", async () => {
|
||||||
|
nock(environment.WORKER_URL!)
|
||||||
|
.delete(`/api/global/roles/${config.getProdAppId()}`)
|
||||||
|
.reply(200, {})
|
||||||
|
|
||||||
const res = await makeRequest(
|
const res = await makeRequest(
|
||||||
"delete",
|
"delete",
|
||||||
`/applications/${config.getAppId()}`
|
`/applications/${config.getAppId()}`
|
||||||
)
|
)
|
||||||
expect(res).toSatisfyApiSpec()
|
expect(res).toSatisfyApiSpec()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("check the tables endpoints", () => {
|
describe("check the tables endpoints", () => {
|
||||||
it("should allow retrieving tables through search", async () => {
|
it("should allow retrieving tables through search", async () => {
|
||||||
await config.createApp("new app 1")
|
await config.createApp("new app 1")
|
||||||
table = await config.upsertTable()
|
table = await config.upsertTable()
|
||||||
|
@ -103,15 +114,19 @@ describe("check the tables endpoints", () => {
|
||||||
const res = await makeRequest("delete", `/tables/${table._id}`)
|
const res = await makeRequest("delete", `/tables/${table._id}`)
|
||||||
expect(res).toSatisfyApiSpec()
|
expect(res).toSatisfyApiSpec()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("check the rows endpoints", () => {
|
describe("check the rows endpoints", () => {
|
||||||
let row: Row
|
let row: Row
|
||||||
it("should allow retrieving rows through search", async () => {
|
it("should allow retrieving rows through search", async () => {
|
||||||
table = await config.upsertTable()
|
table = await config.upsertTable()
|
||||||
const res = await makeRequest("post", `/tables/${table._id}/rows/search`, {
|
const res = await makeRequest(
|
||||||
|
"post",
|
||||||
|
`/tables/${table._id}/rows/search`,
|
||||||
|
{
|
||||||
query: {},
|
query: {},
|
||||||
})
|
}
|
||||||
|
)
|
||||||
expect(res).toSatisfyApiSpec()
|
expect(res).toSatisfyApiSpec()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -135,7 +150,10 @@ describe("check the rows endpoints", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should allow retrieving a row", async () => {
|
it("should allow retrieving a row", async () => {
|
||||||
const res = await makeRequest("get", `/tables/${table._id}/rows/${row._id}`)
|
const res = await makeRequest(
|
||||||
|
"get",
|
||||||
|
`/tables/${table._id}/rows/${row._id}`
|
||||||
|
)
|
||||||
expect(res).toSatisfyApiSpec()
|
expect(res).toSatisfyApiSpec()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -146,40 +164,12 @@ describe("check the rows endpoints", () => {
|
||||||
)
|
)
|
||||||
expect(res).toSatisfyApiSpec()
|
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 () => {
|
describe("check the queries endpoints", () => {
|
||||||
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 () => {
|
it("should allow retrieving queries through search", async () => {
|
||||||
const res = await makeRequest("post", "/queries/search")
|
const res = await makeRequest("post", "/queries/search")
|
||||||
expect(res).toSatisfyApiSpec()
|
expect(res).toSatisfyApiSpec()
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,132 +1,143 @@
|
||||||
import * as setup from "../../tests/utilities"
|
import * as setup from "../../tests/utilities"
|
||||||
import { generateMakeRequest, MakeRequestResponse } from "./utils"
|
|
||||||
import { User } from "@budibase/types"
|
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)
|
beforeAll(async () => {
|
||||||
|
|
||||||
let config = setup.getConfig()
|
|
||||||
let apiKey: string, globalUser: User, makeRequest: MakeRequestResponse
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await config.init()
|
await config.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(setup.afterAll)
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
globalUser = await config.globalUser()
|
globalUser = await config.globalUser()
|
||||||
apiKey = await config.generateApiKey(globalUser._id)
|
|
||||||
makeRequest = generateMakeRequest(apiKey)
|
nock.cleanAll()
|
||||||
mockedWorkerReq.readGlobalUser.mockImplementation(() =>
|
mockWorkerUserAPI(globalUser)
|
||||||
Promise.resolve(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)
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not allow a user to delete themselves", async () => {
|
describe("create", () => {
|
||||||
const res = await makeRequest("delete", `/users/${globalUser._id}`)
|
it("can successfully create a new user", async () => {
|
||||||
expect(res.status).toBe(405)
|
const email = generator.email({ domain: "example.com" })
|
||||||
expect(mockedWorkerReq.deleteGlobalUser.mock.lastCall).toBeUndefined()
|
const newUser = await config.api.public.user.create({
|
||||||
|
email,
|
||||||
|
roles: {},
|
||||||
})
|
})
|
||||||
})
|
expect(newUser.email).toBe(email)
|
||||||
|
expect(newUser._id).toBeDefined()
|
||||||
describe("no user role update in free", () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
updateMock()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("role creation on free tier", () => {
|
||||||
it("should not allow 'roles' to be updated", async () => {
|
it("should not allow 'roles' to be updated", async () => {
|
||||||
const res = await makeRequest("post", "/users", {
|
const newUser = await config.api.public.user.create({
|
||||||
...base(),
|
email: generator.email({ domain: "example.com" }),
|
||||||
roles: { app_a: "BASIC" },
|
roles: { app_a: "BASIC" },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(newUser.roles["app_a"]).toBeUndefined()
|
||||||
expect(res.body.data.roles["app_a"]).toBeUndefined()
|
|
||||||
expect(res.body.message).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not allow 'admin' to be updated", async () => {
|
it("should not allow 'admin' to be updated", async () => {
|
||||||
const res = await makeRequest("post", "/users", {
|
const newUser = await config.api.public.user.create({
|
||||||
...base(),
|
email: generator.email({ domain: "example.com" }),
|
||||||
|
roles: {},
|
||||||
admin: { global: true },
|
admin: { global: true },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(newUser.admin).toBeUndefined()
|
||||||
expect(res.body.data.admin).toBeUndefined()
|
|
||||||
expect(res.body.message).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should not allow 'builder' to be updated", async () => {
|
it("should not allow 'builder' to be updated", async () => {
|
||||||
const res = await makeRequest("post", "/users", {
|
const newUser = await config.api.public.user.create({
|
||||||
...base(),
|
email: generator.email({ domain: "example.com" }),
|
||||||
|
roles: {},
|
||||||
builder: { global: true },
|
builder: { global: true },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(newUser.builder).toBeUndefined()
|
||||||
expect(res.body.data.builder).toBeUndefined()
|
})
|
||||||
expect(res.body.message).toBeDefined()
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe("no user role update in business", () => {
|
describe("role creation on business tier", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
updateMock()
|
|
||||||
mocks.licenses.useExpandedPublicApi()
|
mocks.licenses.useExpandedPublicApi()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should allow 'roles' to be updated", async () => {
|
it("should allow 'roles' to be updated", async () => {
|
||||||
const res = await makeRequest("post", "/users", {
|
const newUser = await config.api.public.user.create({
|
||||||
...base(),
|
email: generator.email({ domain: "example.com" }),
|
||||||
roles: { app_a: "BASIC" },
|
roles: { app_a: "BASIC" },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(newUser.roles["app_a"]).toBe("BASIC")
|
||||||
expect(res.body.data.roles["app_a"]).toBe("BASIC")
|
|
||||||
expect(res.body.message).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should allow 'admin' to be updated", async () => {
|
it("should allow 'admin' to be updated", async () => {
|
||||||
mocks.licenses.useExpandedPublicApi()
|
const newUser = await config.api.public.user.create({
|
||||||
const res = await makeRequest("post", "/users", {
|
email: generator.email({ domain: "example.com" }),
|
||||||
...base(),
|
roles: {},
|
||||||
admin: { global: true },
|
admin: { global: true },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(newUser.admin?.global).toBe(true)
|
||||||
expect(res.body.data.admin.global).toBe(true)
|
|
||||||
expect(res.body.message).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should allow 'builder' to be updated", async () => {
|
it("should allow 'builder' to be updated", async () => {
|
||||||
mocks.licenses.useExpandedPublicApi()
|
const newUser = await config.api.public.user.create({
|
||||||
const res = await makeRequest("post", "/users", {
|
email: generator.email({ domain: "example.com" }),
|
||||||
...base(),
|
roles: {},
|
||||||
builder: { global: true },
|
builder: { global: true },
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(newUser.builder?.global).toBe(true)
|
||||||
expect(res.body.data.builder.global).toBe(true)
|
})
|
||||||
expect(res.body.message).toBeUndefined()
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import * as setup from "../../tests/utilities"
|
import * as setup from "../../tests/utilities"
|
||||||
import { checkSlashesInUrl } from "../../../../utilities"
|
import { checkSlashesInUrl } from "../../../../utilities"
|
||||||
import supertest from "supertest"
|
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"
|
export type HttpMethod = "post" | "get" | "put" | "delete" | "patch"
|
||||||
|
|
||||||
|
@ -91,3 +95,43 @@ export function generateMakeRequestWithFormData(
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mockWorkerUserAPI(...seedUsers: User[]) {
|
||||||
|
const users: Record<string, User> = {
|
||||||
|
...seedUsers.reduce((acc, user) => {
|
||||||
|
acc[user._id!] = user
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, User>),
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
|
@ -1,153 +1,106 @@
|
||||||
const pg = require("pg")
|
import { structures } from "./utilities"
|
||||||
|
|
||||||
jest.mock("pg", () => {
|
|
||||||
return {
|
|
||||||
Client: jest.fn().mockImplementation(() => ({
|
|
||||||
connect: jest.fn(),
|
|
||||||
query: jest.fn().mockImplementation(() => ({ rows: [] })),
|
|
||||||
end: jest.fn().mockImplementation((fn: any) => fn()),
|
|
||||||
})),
|
|
||||||
queryMock: jest.fn().mockImplementation(() => {}),
|
|
||||||
on: jest.fn(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
import * as setup from "./utilities"
|
|
||||||
import { mocks } from "@budibase/backend-core/tests"
|
import { mocks } from "@budibase/backend-core/tests"
|
||||||
import { env, events } from "@budibase/backend-core"
|
import { setEnv } from "@budibase/backend-core"
|
||||||
import { QueryPreview } from "@budibase/types"
|
import { Datasource } from "@budibase/types"
|
||||||
|
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||||
|
import {
|
||||||
|
DatabaseName,
|
||||||
|
datasourceDescribe,
|
||||||
|
} from "../../../integrations/tests/utils"
|
||||||
|
|
||||||
const structures = setup.structures
|
const describes = datasourceDescribe({ only: [DatabaseName.POSTGRES] })
|
||||||
|
|
||||||
env._set("ENCRYPTION_KEY", "budibase")
|
if (describes.length > 0) {
|
||||||
mocks.licenses.useEnvironmentVariables()
|
describe.each(describes)("/api/env/variables", ({ dsProvider }) => {
|
||||||
|
const config = new TestConfiguration()
|
||||||
|
|
||||||
describe("/api/env/variables", () => {
|
let rawDatasource: Datasource
|
||||||
let request = setup.getRequest()
|
let restoreEnv: () => void
|
||||||
let config = setup.getConfig()
|
|
||||||
|
|
||||||
afterAll(setup.afterAll)
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
restoreEnv = setEnv({ ENCRYPTION_KEY: "budibase" })
|
||||||
|
mocks.licenses.useEnvironmentVariables()
|
||||||
|
|
||||||
|
const ds = await dsProvider()
|
||||||
|
rawDatasource = ds.rawDatasource!
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
restoreEnv()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { variables } = await config.api.environment.fetch()
|
||||||
|
for (const variable of variables) {
|
||||||
|
await config.api.environment.destroy(variable)
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.api.environment.create({
|
||||||
|
name: "test",
|
||||||
|
production: rawDatasource.config!.password,
|
||||||
|
development: rawDatasource.config!.password,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able check the status of env var API", async () => {
|
it("should be able check the status of env var API", async () => {
|
||||||
const res = await request
|
const { encryptionKeyAvailable } = await config.api.environment.status()
|
||||||
.get(`/api/env/variables/status`)
|
expect(encryptionKeyAvailable).toEqual(true)
|
||||||
.set(config.defaultHeaders())
|
|
||||||
.expect("Content-Type", /json/)
|
|
||||||
.expect(200)
|
|
||||||
|
|
||||||
expect(res.body.encryptionKeyAvailable).toEqual(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should be able to create an environment variable", async () => {
|
|
||||||
await request
|
|
||||||
.post(`/api/env/variables`)
|
|
||||||
.send(structures.basicEnvironmentVariable("test", "test"))
|
|
||||||
.set(config.defaultHeaders())
|
|
||||||
.expect(200)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to fetch the 'test' variable name", async () => {
|
it("should be able to fetch the 'test' variable name", async () => {
|
||||||
const res = await request
|
const { variables } = await config.api.environment.fetch()
|
||||||
.get(`/api/env/variables`)
|
expect(variables.length).toEqual(1)
|
||||||
.set(config.defaultHeaders())
|
expect(variables[0]).toEqual("test")
|
||||||
.expect("Content-Type", /json/)
|
|
||||||
.expect(200)
|
|
||||||
expect(res.body.variables.length).toEqual(1)
|
|
||||||
expect(res.body.variables[0]).toEqual("test")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to update the environment variable 'test'", async () => {
|
it("should be able to update the environment variable 'test'", async () => {
|
||||||
const varName = "test"
|
await config.api.environment.update("test", {
|
||||||
await request
|
production: "test1",
|
||||||
.patch(`/api/env/variables/${varName}`)
|
development: "test1",
|
||||||
.send(structures.basicEnvironmentVariable("test", "test1"))
|
})
|
||||||
.set(config.defaultHeaders())
|
|
||||||
.expect(200)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to delete the environment variable 'test'", async () => {
|
it("should be able to delete the environment variable 'test'", async () => {
|
||||||
const varName = "test"
|
await config.api.environment.destroy("test")
|
||||||
await request
|
|
||||||
.delete(`/api/env/variables/${varName}`)
|
|
||||||
.set(config.defaultHeaders())
|
|
||||||
.expect(200)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should create a datasource (using the environment variable) and query", async () => {
|
it("should create a datasource (using the environment variable) and query", async () => {
|
||||||
const datasourceBase = structures.basicDatasource()
|
const datasource = await config.api.datasource.create({
|
||||||
await request
|
...structures.basicDatasource().datasource,
|
||||||
.post(`/api/env/variables`)
|
config: {
|
||||||
.send(structures.basicEnvironmentVariable("test", "test"))
|
...rawDatasource.config,
|
||||||
.set(config.defaultHeaders())
|
|
||||||
|
|
||||||
datasourceBase.datasource.config = {
|
|
||||||
password: "{{ env.test }}",
|
password: "{{ env.test }}",
|
||||||
}
|
},
|
||||||
const response = await request
|
})
|
||||||
.post(`/api/datasources`)
|
|
||||||
.send(datasourceBase)
|
|
||||||
.set(config.defaultHeaders())
|
|
||||||
.expect("Content-Type", /json/)
|
|
||||||
.expect(200)
|
|
||||||
expect(response.body.datasource._id).toBeDefined()
|
|
||||||
|
|
||||||
const response2 = await request
|
const query = await config.api.query.save({
|
||||||
.post(`/api/queries`)
|
...structures.basicQuery(datasource._id!),
|
||||||
.send(structures.basicQuery(response.body.datasource._id))
|
fields: { sql: "SELECT 1" },
|
||||||
.set(config.defaultHeaders())
|
})
|
||||||
.expect("Content-Type", /json/)
|
expect(query._id).toBeDefined()
|
||||||
.expect(200)
|
|
||||||
expect(response2.body._id).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should run a query preview and check the mocked results", async () => {
|
it("should run a query preview and check the mocked results", async () => {
|
||||||
const datasourceBase = structures.basicDatasource()
|
const datasource = await config.api.datasource.create({
|
||||||
await request
|
...structures.basicDatasource().datasource,
|
||||||
.post(`/api/env/variables`)
|
config: {
|
||||||
.send(structures.basicEnvironmentVariable("test", "test"))
|
...rawDatasource.config,
|
||||||
.set(config.defaultHeaders())
|
|
||||||
|
|
||||||
datasourceBase.datasource.config = {
|
|
||||||
password: "{{ env.test }}",
|
password: "{{ env.test }}",
|
||||||
}
|
},
|
||||||
const response = await request
|
|
||||||
.post(`/api/datasources`)
|
|
||||||
.send(datasourceBase)
|
|
||||||
.set(config.defaultHeaders())
|
|
||||||
.expect("Content-Type", /json/)
|
|
||||||
.expect(200)
|
|
||||||
expect(response.body.datasource._id).toBeDefined()
|
|
||||||
|
|
||||||
const queryPreview: QueryPreview = {
|
|
||||||
datasourceId: response.body.datasource._id,
|
|
||||||
parameters: [],
|
|
||||||
fields: {},
|
|
||||||
queryVerb: "read",
|
|
||||||
name: response.body.datasource.name,
|
|
||||||
transformer: null,
|
|
||||||
schema: {},
|
|
||||||
readable: true,
|
|
||||||
}
|
|
||||||
const res = await request
|
|
||||||
.post(`/api/queries/preview`)
|
|
||||||
.send(queryPreview)
|
|
||||||
.set(config.defaultHeaders())
|
|
||||||
.expect("Content-Type", /json/)
|
|
||||||
.expect(200)
|
|
||||||
expect(res.body.rows.length).toEqual(0)
|
|
||||||
expect(events.query.previewed).toHaveBeenCalledTimes(1)
|
|
||||||
// API doesn't include config in response
|
|
||||||
delete response.body.datasource.config
|
|
||||||
expect(events.query.previewed).toHaveBeenCalledWith(
|
|
||||||
response.body.datasource,
|
|
||||||
{
|
|
||||||
...queryPreview,
|
|
||||||
nullDefaultSupport: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
expect(pg.Client).toHaveBeenCalledWith({ password: "test", ssl: undefined })
|
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
const query = await config.api.query.save({
|
||||||
|
...structures.basicQuery(datasource._id!),
|
||||||
|
fields: { sql: "SELECT 1 as id" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { rows } = await config.api.query.preview({
|
||||||
|
...query,
|
||||||
|
queryId: query._id!,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toEqual([{ id: 1 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
docIds,
|
docIds,
|
||||||
MAX_VALID_DATE,
|
MAX_VALID_DATE,
|
||||||
MIN_VALID_DATE,
|
MIN_VALID_DATE,
|
||||||
|
setEnv,
|
||||||
SQLITE_DESIGN_DOC_ID,
|
SQLITE_DESIGN_DOC_ID,
|
||||||
utils,
|
utils,
|
||||||
withEnv as withCoreEnv,
|
withEnv as withCoreEnv,
|
||||||
|
@ -43,19 +44,7 @@ import { generator, structures, mocks } from "@budibase/backend-core/tests"
|
||||||
import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default"
|
import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default"
|
||||||
import { generateRowIdField } from "../../../integrations/utils"
|
import { generateRowIdField } from "../../../integrations/utils"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
|
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai"
|
||||||
jest.mock("@budibase/pro", () => ({
|
|
||||||
...jest.requireActual("@budibase/pro"),
|
|
||||||
ai: {
|
|
||||||
LargeLanguageModel: {
|
|
||||||
forCurrentTenant: async () => ({
|
|
||||||
llm: {},
|
|
||||||
run: jest.fn(() => `Mock LLM Response`),
|
|
||||||
buildPromptFromAIOperation: jest.fn(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const descriptions = datasourceDescribe({ plus: true })
|
const descriptions = datasourceDescribe({ plus: true })
|
||||||
|
|
||||||
|
@ -1896,11 +1885,15 @@ if (descriptions.length) {
|
||||||
!isInMemory &&
|
!isInMemory &&
|
||||||
describe("AI Column", () => {
|
describe("AI Column", () => {
|
||||||
const UNEXISTING_AI_COLUMN = "Real LLM Response"
|
const UNEXISTING_AI_COLUMN = "Real LLM Response"
|
||||||
|
let envCleanup: () => void
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
mocks.licenses.useBudibaseAI()
|
mocks.licenses.useBudibaseAI()
|
||||||
mocks.licenses.useAICustomConfigs()
|
mocks.licenses.useAICustomConfigs()
|
||||||
|
|
||||||
|
envCleanup = setEnv({ OPENAI_API_KEY: "mock" })
|
||||||
|
mockChatGPTResponse("Mock LLM Response")
|
||||||
|
|
||||||
tableOrViewId = await createTableOrView({
|
tableOrViewId = await createTableOrView({
|
||||||
product: { name: "product", type: FieldType.STRING },
|
product: { name: "product", type: FieldType.STRING },
|
||||||
ai: {
|
ai: {
|
||||||
|
@ -1917,6 +1910,10 @@ if (descriptions.length) {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
envCleanup()
|
||||||
|
})
|
||||||
|
|
||||||
describe("equal", () => {
|
describe("equal", () => {
|
||||||
it("successfully finds rows based on AI column", async () => {
|
it("successfully finds rows based on AI column", async () => {
|
||||||
await expectQuery({
|
await expectQuery({
|
||||||
|
|
|
@ -3,44 +3,6 @@ import supertest from "supertest"
|
||||||
|
|
||||||
export * as structures from "../../../../tests/utilities/structures"
|
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) {
|
export function delay(ms: number) {
|
||||||
return new Promise(resolve => setTimeout(resolve, ms))
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ describe("Execute Bash Automations", () => {
|
||||||
name: "test row",
|
name: "test row",
|
||||||
description: "test description",
|
description: "test description",
|
||||||
})
|
})
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -33,6 +33,7 @@ describe("test the create row action", () => {
|
||||||
name: "test",
|
name: "test",
|
||||||
description: "test",
|
description: "test",
|
||||||
}
|
}
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -6,6 +6,7 @@ describe("test the delay logic", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -13,6 +13,7 @@ describe("test the delete row action", () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
table = await config.api.table.save(basicTable())
|
table = await config.api.table.save(basicTable())
|
||||||
row = await config.api.row.save(table._id!, {})
|
row = await config.api.row.save(table._id!, {})
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -7,6 +7,7 @@ describe("test the outgoing webhook action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -26,6 +26,7 @@ if (descriptions.length) {
|
||||||
const ds = await dsProvider()
|
const ds = await dsProvider()
|
||||||
datasource = ds.datasource!
|
datasource = ds.datasource!
|
||||||
client = ds.client!
|
client = ds.client!
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
@ -13,6 +13,7 @@ describe("Execute Script Automations", () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
table = await config.api.table.save(basicTable())
|
table = await config.api.table.save(basicTable())
|
||||||
await config.api.row.save(table._id!, {})
|
await config.api.row.save(table._id!, {})
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -26,6 +26,7 @@ describe("test the filter logic", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -22,10 +22,7 @@ describe("Attempt to run a basic loop automation", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { automations } = await config.api.automation.fetch()
|
await config.api.automation.deleteAll()
|
||||||
for (const automation of automations) {
|
|
||||||
await config.api.automation.delete(automation)
|
|
||||||
}
|
|
||||||
|
|
||||||
table = await config.api.table.save(basicTable())
|
table = await config.api.table.save(basicTable())
|
||||||
await config.api.row.save(table._id!, {})
|
await config.api.row.save(table._id!, {})
|
||||||
|
|
|
@ -7,6 +7,7 @@ describe("test the outgoing webhook action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -8,6 +8,7 @@ describe("test the outgoing webhook action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -16,6 +16,7 @@ describe("test the openai action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
|
@ -8,6 +8,7 @@ describe("test the outgoing webhook action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -21,6 +21,7 @@ describe("Test a query step automation", () => {
|
||||||
}
|
}
|
||||||
await config.api.row.save(table._id!, row)
|
await config.api.row.save(table._id!, row)
|
||||||
await config.api.row.save(table._id!, row)
|
await config.api.row.save(table._id!, row)
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -28,6 +28,7 @@ describe("test the outgoing webhook action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -6,6 +6,7 @@ describe("test the server log action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -9,6 +9,7 @@ describe("Test triggering an automation from another automation", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await automation.init()
|
await automation.init()
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|
|
@ -23,6 +23,7 @@ describe("test the update row action", () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
table = await config.createTable()
|
table = await config.createTable()
|
||||||
row = await config.createRow()
|
row = await config.createRow()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -7,6 +7,7 @@ describe("test the outgoing webhook action", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -9,6 +9,8 @@ describe("app action trigger", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
|
|
||||||
automation = await createAutomationBuilder(config)
|
automation = await createAutomationBuilder(config)
|
||||||
.onAppAction()
|
.onAppAction()
|
||||||
.serverLog({
|
.serverLog({
|
||||||
|
|
|
@ -16,6 +16,7 @@ describe("cron trigger", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|
|
@ -11,6 +11,7 @@ describe("row deleted trigger", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
table = await config.api.table.save(basicTable())
|
table = await config.api.table.save(basicTable())
|
||||||
automation = await createAutomationBuilder(config)
|
automation = await createAutomationBuilder(config)
|
||||||
.onRowDeleted({ tableId: table._id! })
|
.onRowDeleted({ tableId: table._id! })
|
||||||
|
|
|
@ -11,6 +11,7 @@ describe("row saved trigger", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
table = await config.api.table.save(basicTable())
|
table = await config.api.table.save(basicTable())
|
||||||
automation = await createAutomationBuilder(config)
|
automation = await createAutomationBuilder(config)
|
||||||
.onRowSaved({ tableId: table._id! })
|
.onRowSaved({ tableId: table._id! })
|
||||||
|
|
|
@ -11,6 +11,7 @@ describe("row updated trigger", () => {
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
table = await config.api.table.save(basicTable())
|
table = await config.api.table.save(basicTable())
|
||||||
automation = await createAutomationBuilder(config)
|
automation = await createAutomationBuilder(config)
|
||||||
.onRowUpdated({ tableId: table._id! })
|
.onRowUpdated({ tableId: table._id! })
|
||||||
|
|
|
@ -37,6 +37,7 @@ describe("Webhook trigger test", () => {
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await config.init()
|
await config.init()
|
||||||
|
await config.api.automation.deleteAll()
|
||||||
table = await config.createTable()
|
table = await config.createTable()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,11 @@ import {
|
||||||
|
|
||||||
import { Database, aql } from "arangojs"
|
import { Database, aql } from "arangojs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 3rd March 2025
|
||||||
|
* datasource disabled - this datasource is marked for deprecation and removal
|
||||||
|
*/
|
||||||
|
|
||||||
interface ArangodbConfig {
|
interface ArangodbConfig {
|
||||||
url: string
|
url: string
|
||||||
username: string
|
username: string
|
||||||
|
|
|
@ -33,15 +33,17 @@ const DEFINITIONS: Record<SourceName, Integration | undefined> = {
|
||||||
[SourceName.COUCHDB]: couchdb.schema,
|
[SourceName.COUCHDB]: couchdb.schema,
|
||||||
[SourceName.SQL_SERVER]: sqlServer.schema,
|
[SourceName.SQL_SERVER]: sqlServer.schema,
|
||||||
[SourceName.S3]: s3.schema,
|
[SourceName.S3]: s3.schema,
|
||||||
[SourceName.AIRTABLE]: airtable.schema,
|
|
||||||
[SourceName.MYSQL]: mysql.schema,
|
[SourceName.MYSQL]: mysql.schema,
|
||||||
[SourceName.ARANGODB]: arangodb.schema,
|
|
||||||
[SourceName.REST]: rest.schema,
|
[SourceName.REST]: rest.schema,
|
||||||
[SourceName.FIRESTORE]: firebase.schema,
|
[SourceName.FIRESTORE]: firebase.schema,
|
||||||
[SourceName.GOOGLE_SHEETS]: googlesheets.schema,
|
[SourceName.GOOGLE_SHEETS]: googlesheets.schema,
|
||||||
[SourceName.REDIS]: redis.schema,
|
[SourceName.REDIS]: redis.schema,
|
||||||
[SourceName.SNOWFLAKE]: snowflake.schema,
|
[SourceName.SNOWFLAKE]: snowflake.schema,
|
||||||
[SourceName.ORACLE]: oracle.schema,
|
[SourceName.ORACLE]: oracle.schema,
|
||||||
|
/* deprecated - not available through UI */
|
||||||
|
[SourceName.ARANGODB]: arangodb.schema,
|
||||||
|
[SourceName.AIRTABLE]: airtable.schema,
|
||||||
|
/* un-used */
|
||||||
[SourceName.BUDIBASE]: undefined,
|
[SourceName.BUDIBASE]: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,15 +58,17 @@ const INTEGRATIONS: Record<SourceName, IntegrationBaseConstructor | undefined> =
|
||||||
[SourceName.COUCHDB]: couchdb.integration,
|
[SourceName.COUCHDB]: couchdb.integration,
|
||||||
[SourceName.SQL_SERVER]: sqlServer.integration,
|
[SourceName.SQL_SERVER]: sqlServer.integration,
|
||||||
[SourceName.S3]: s3.integration,
|
[SourceName.S3]: s3.integration,
|
||||||
[SourceName.AIRTABLE]: airtable.integration,
|
|
||||||
[SourceName.MYSQL]: mysql.integration,
|
[SourceName.MYSQL]: mysql.integration,
|
||||||
[SourceName.ARANGODB]: arangodb.integration,
|
|
||||||
[SourceName.REST]: rest.integration,
|
[SourceName.REST]: rest.integration,
|
||||||
[SourceName.FIRESTORE]: firebase.integration,
|
[SourceName.FIRESTORE]: firebase.integration,
|
||||||
[SourceName.GOOGLE_SHEETS]: googlesheets.integration,
|
[SourceName.GOOGLE_SHEETS]: googlesheets.integration,
|
||||||
[SourceName.REDIS]: redis.integration,
|
[SourceName.REDIS]: redis.integration,
|
||||||
[SourceName.SNOWFLAKE]: snowflake.integration,
|
[SourceName.SNOWFLAKE]: snowflake.integration,
|
||||||
[SourceName.ORACLE]: oracle.integration,
|
[SourceName.ORACLE]: oracle.integration,
|
||||||
|
/* deprecated - not available through UI */
|
||||||
|
[SourceName.ARANGODB]: arangodb.integration,
|
||||||
|
[SourceName.AIRTABLE]: airtable.integration,
|
||||||
|
/* un-used */
|
||||||
[SourceName.BUDIBASE]: undefined,
|
[SourceName.BUDIBASE]: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,76 +0,0 @@
|
||||||
import { default as AirtableIntegration } from "../airtable"
|
|
||||||
|
|
||||||
jest.mock("airtable")
|
|
||||||
|
|
||||||
class TestConfiguration {
|
|
||||||
integration: any
|
|
||||||
client: any
|
|
||||||
|
|
||||||
constructor(config: any = {}) {
|
|
||||||
this.integration = new AirtableIntegration.integration(config)
|
|
||||||
this.client = {
|
|
||||||
create: jest.fn(),
|
|
||||||
select: jest.fn(() => ({
|
|
||||||
firstPage: jest.fn(() => []),
|
|
||||||
})),
|
|
||||||
update: jest.fn(),
|
|
||||||
destroy: jest.fn(),
|
|
||||||
}
|
|
||||||
this.integration.client = () => this.client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Airtable Integration", () => {
|
|
||||||
let config: any
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
config = new TestConfiguration()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("calls the create method with the correct params", async () => {
|
|
||||||
await config.integration.create({
|
|
||||||
table: "test",
|
|
||||||
json: {},
|
|
||||||
})
|
|
||||||
expect(config.client.create).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
fields: {},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("calls the read method with the correct params", async () => {
|
|
||||||
await config.integration.read({
|
|
||||||
table: "test",
|
|
||||||
view: "Grid view",
|
|
||||||
})
|
|
||||||
expect(config.client.select).toHaveBeenCalledWith({
|
|
||||||
maxRecords: 10,
|
|
||||||
view: "Grid view",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("calls the update method with the correct params", async () => {
|
|
||||||
await config.integration.update({
|
|
||||||
table: "table",
|
|
||||||
id: "123",
|
|
||||||
json: {
|
|
||||||
name: "test",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(config.client.update).toHaveBeenCalledWith([
|
|
||||||
{
|
|
||||||
id: "123",
|
|
||||||
fields: { name: "test" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it("calls the delete method with the correct params", async () => {
|
|
||||||
const ids = [1, 2, 3, 4]
|
|
||||||
await config.integration.delete({
|
|
||||||
ids,
|
|
||||||
})
|
|
||||||
expect(config.client.destroy).toHaveBeenCalledWith(ids)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,38 +0,0 @@
|
||||||
import { default as ArangoDBIntegration } from "../arangodb"
|
|
||||||
|
|
||||||
jest.mock("arangojs")
|
|
||||||
|
|
||||||
class TestConfiguration {
|
|
||||||
integration: any
|
|
||||||
|
|
||||||
constructor(config: any = {}) {
|
|
||||||
this.integration = new ArangoDBIntegration.integration(config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ArangoDB Integration", () => {
|
|
||||||
let config: any
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
config = new TestConfiguration()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("calls the create method with the correct params", async () => {
|
|
||||||
const body = {
|
|
||||||
json: "Hello",
|
|
||||||
}
|
|
||||||
|
|
||||||
await config.integration.create(body)
|
|
||||||
expect(config.integration.client.query).toHaveBeenCalledWith(
|
|
||||||
`INSERT Hello INTO collection RETURN NEW`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("calls the read method with the correct params", async () => {
|
|
||||||
const query = {
|
|
||||||
sql: `test`,
|
|
||||||
}
|
|
||||||
await config.integration.read(query)
|
|
||||||
expect(config.integration.client.query).toHaveBeenCalledWith(query.sql)
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -67,6 +67,7 @@ import {
|
||||||
View,
|
View,
|
||||||
Webhook,
|
Webhook,
|
||||||
WithRequired,
|
WithRequired,
|
||||||
|
DevInfo,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
import API from "./api"
|
import API from "./api"
|
||||||
|
@ -248,7 +249,7 @@ export default class TestConfiguration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async withUser(user: User, f: () => Promise<void>) {
|
async withUser<T>(user: User, f: () => Promise<T>): Promise<T> {
|
||||||
const oldUser = this.user
|
const oldUser = this.user
|
||||||
this.user = user
|
this.user = user
|
||||||
try {
|
try {
|
||||||
|
@ -469,7 +470,10 @@ export default class TestConfiguration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultHeaders(extras = {}, prodApp = false) {
|
defaultHeaders(
|
||||||
|
extras: Record<string, string | string[]> = {},
|
||||||
|
prodApp = false
|
||||||
|
) {
|
||||||
const tenantId = this.getTenantId()
|
const tenantId = this.getTenantId()
|
||||||
const user = this.getUser()
|
const user = this.getUser()
|
||||||
const authObj: AuthToken = {
|
const authObj: AuthToken = {
|
||||||
|
@ -498,10 +502,13 @@ export default class TestConfiguration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
publicHeaders({ prodApp = true } = {}) {
|
publicHeaders({
|
||||||
|
prodApp = true,
|
||||||
|
extras = {},
|
||||||
|
}: { prodApp?: boolean; extras?: Record<string, string | string[]> } = {}) {
|
||||||
const appId = prodApp ? this.prodAppId : this.appId
|
const appId = prodApp ? this.prodAppId : this.appId
|
||||||
|
|
||||||
const headers: any = {
|
const headers: Record<string, string> = {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Cookie: "",
|
Cookie: "",
|
||||||
}
|
}
|
||||||
|
@ -514,6 +521,7 @@ export default class TestConfiguration {
|
||||||
return {
|
return {
|
||||||
...headers,
|
...headers,
|
||||||
...this.temporaryHeaders,
|
...this.temporaryHeaders,
|
||||||
|
...extras,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -577,17 +585,17 @@ export default class TestConfiguration {
|
||||||
}
|
}
|
||||||
const db = tenancy.getTenantDB(this.getTenantId())
|
const db = tenancy.getTenantDB(this.getTenantId())
|
||||||
const id = dbCore.generateDevInfoID(userId)
|
const id = dbCore.generateDevInfoID(userId)
|
||||||
let devInfo: any
|
const devInfo = await db.tryGet<DevInfo>(id)
|
||||||
try {
|
if (devInfo && devInfo.apiKey) {
|
||||||
devInfo = await db.get(id)
|
return devInfo.apiKey
|
||||||
} catch (err) {
|
|
||||||
devInfo = { _id: id, userId }
|
|
||||||
}
|
}
|
||||||
devInfo.apiKey = encryption.encrypt(
|
|
||||||
|
const apiKey = encryption.encrypt(
|
||||||
`${this.getTenantId()}${dbCore.SEPARATOR}${newid()}`
|
`${this.getTenantId()}${dbCore.SEPARATOR}${newid()}`
|
||||||
)
|
)
|
||||||
await db.put(devInfo)
|
const newDevInfo: DevInfo = { _id: id, userId, apiKey }
|
||||||
return devInfo.apiKey
|
await db.put(newDevInfo)
|
||||||
|
return apiKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// APP
|
// APP
|
||||||
|
|
|
@ -133,4 +133,11 @@ export class AutomationAPI extends TestAPI {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteAll = async (expectations?: Expectations): Promise<void> => {
|
||||||
|
const { automations } = await this.fetch()
|
||||||
|
await Promise.all(
|
||||||
|
automations.map(automation => this.delete(automation, expectations))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
|
import jestOpenAPI from "jest-openapi"
|
||||||
|
import { spec } from "../../../../specs/generate"
|
||||||
import TestConfiguration from "../TestConfiguration"
|
import TestConfiguration from "../TestConfiguration"
|
||||||
import request, { SuperTest, Test, Response } from "supertest"
|
import request, { SuperTest, Test, Response } from "supertest"
|
||||||
import { ReadStream } from "fs"
|
import { ReadStream } from "fs"
|
||||||
import { getServer } from "../../../app"
|
import { getServer } from "../../../app"
|
||||||
|
|
||||||
|
jestOpenAPI(spec() as any)
|
||||||
|
|
||||||
type Headers = Record<string, string | string[] | undefined>
|
type Headers = Record<string, string | string[] | undefined>
|
||||||
type Method = "get" | "post" | "put" | "patch" | "delete"
|
type Method = "get" | "post" | "put" | "patch" | "delete"
|
||||||
|
|
||||||
|
@ -46,6 +50,7 @@ export interface RequestOpts {
|
||||||
export abstract class TestAPI {
|
export abstract class TestAPI {
|
||||||
config: TestConfiguration
|
config: TestConfiguration
|
||||||
request: SuperTest<Test>
|
request: SuperTest<Test>
|
||||||
|
prefix = ""
|
||||||
|
|
||||||
constructor(config: TestConfiguration) {
|
constructor(config: TestConfiguration) {
|
||||||
this.config = config
|
this.config = config
|
||||||
|
@ -53,26 +58,26 @@ export abstract class TestAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _get = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
protected _get = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||||
return await this._request<T>("get", url, opts)
|
return await this._request<T>("get", `${this.prefix}${url}`, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _post = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
protected _post = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||||
return await this._request<T>("post", url, opts)
|
return await this._request<T>("post", `${this.prefix}${url}`, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _put = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
protected _put = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||||
return await this._request<T>("put", url, opts)
|
return await this._request<T>("put", `${this.prefix}${url}`, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _patch = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
protected _patch = async <T>(url: string, opts?: RequestOpts): Promise<T> => {
|
||||||
return await this._request<T>("patch", url, opts)
|
return await this._request<T>("patch", `${this.prefix}${url}`, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _delete = async <T>(
|
protected _delete = async <T>(
|
||||||
url: string,
|
url: string,
|
||||||
opts?: RequestOpts
|
opts?: RequestOpts
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
return await this._request<T>("delete", url, opts)
|
return await this._request<T>("delete", `${this.prefix}${url}`, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _requestRaw = async (
|
protected _requestRaw = async (
|
||||||
|
@ -88,7 +93,6 @@ export abstract class TestAPI {
|
||||||
fields = {},
|
fields = {},
|
||||||
files = {},
|
files = {},
|
||||||
expectations,
|
expectations,
|
||||||
publicUser = false,
|
|
||||||
} = opts || {}
|
} = opts || {}
|
||||||
const { status = 200 } = expectations || {}
|
const { status = 200 } = expectations || {}
|
||||||
const expectHeaders = expectations?.headers || {}
|
const expectHeaders = expectations?.headers || {}
|
||||||
|
@ -97,7 +101,7 @@ export abstract class TestAPI {
|
||||||
expectHeaders["Content-Type"] = /^application\/json/
|
expectHeaders["Content-Type"] = /^application\/json/
|
||||||
}
|
}
|
||||||
|
|
||||||
let queryParams = []
|
let queryParams: string[] = []
|
||||||
for (const [key, value] of Object.entries(query)) {
|
for (const [key, value] of Object.entries(query)) {
|
||||||
if (value) {
|
if (value) {
|
||||||
queryParams.push(`${key}=${value}`)
|
queryParams.push(`${key}=${value}`)
|
||||||
|
@ -107,18 +111,10 @@ export abstract class TestAPI {
|
||||||
url += `?${queryParams.join("&")}`
|
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()
|
const app = getServer()
|
||||||
let req = request(app)[method](url)
|
let req = request(app)[method](url)
|
||||||
req = req.set(
|
req = req.set(
|
||||||
headersFn({
|
await this.getHeaders(opts, {
|
||||||
"x-budibase-include-stacktrace": "true",
|
"x-budibase-include-stacktrace": "true",
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -167,10 +163,18 @@ export abstract class TestAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _checkResponse = (
|
protected async getHeaders(
|
||||||
response: Response,
|
opts?: RequestOpts,
|
||||||
expectations?: Expectations
|
extras?: Record<string, string | string[]>
|
||||||
) => {
|
): Promise<Record<string, string | string[]>> {
|
||||||
|
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 || {}
|
const { status = 200 } = expectations || {}
|
||||||
|
|
||||||
if (response.status !== status) {
|
if (response.status !== status) {
|
||||||
|
@ -236,3 +240,34 @@ export abstract class TestAPI {
|
||||||
).body
|
).body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export abstract class PublicAPI extends TestAPI {
|
||||||
|
prefix = "/api/public/v1"
|
||||||
|
|
||||||
|
protected async getHeaders(
|
||||||
|
opts?: RequestOpts,
|
||||||
|
extras?: Record<string, string | string[]>
|
||||||
|
): Promise<Record<string, string | string[]>> {
|
||||||
|
const apiKey = await this.config.generateApiKey()
|
||||||
|
|
||||||
|
const headers: Record<string, string | string[]> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Expectations, TestAPI } from "./base"
|
||||||
|
import {
|
||||||
|
CreateEnvironmentVariableRequest,
|
||||||
|
CreateEnvironmentVariableResponse,
|
||||||
|
GetEnvironmentVariablesResponse,
|
||||||
|
StatusEnvironmentVariableResponse,
|
||||||
|
UpdateEnvironmentVariableRequest,
|
||||||
|
} from "@budibase/types"
|
||||||
|
|
||||||
|
export class EnvironmentAPI extends TestAPI {
|
||||||
|
create = async (
|
||||||
|
body: CreateEnvironmentVariableRequest,
|
||||||
|
expectations?: Expectations
|
||||||
|
) => {
|
||||||
|
return await this._post<CreateEnvironmentVariableResponse>(
|
||||||
|
`/api/env/variables`,
|
||||||
|
{ body, expectations }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
status = async (expectations?: Expectations) => {
|
||||||
|
return await this._get<StatusEnvironmentVariableResponse>(
|
||||||
|
`/api/env/variables/status`,
|
||||||
|
{ expectations }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch = async (expectations?: Expectations) => {
|
||||||
|
return await this._get<GetEnvironmentVariablesResponse>(
|
||||||
|
`/api/env/variables`,
|
||||||
|
{ expectations }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
update = async (
|
||||||
|
varName: string,
|
||||||
|
body: UpdateEnvironmentVariableRequest,
|
||||||
|
expectations?: Expectations
|
||||||
|
) => {
|
||||||
|
return await this._patch<void>(`/api/env/variables/${varName}`, {
|
||||||
|
body,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy = async (varName: string, expectations?: Expectations) => {
|
||||||
|
return await this._delete<void>(`/api/env/variables/${varName}`, {
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,6 +17,8 @@ import { RowActionAPI } from "./rowAction"
|
||||||
import { AutomationAPI } from "./automation"
|
import { AutomationAPI } from "./automation"
|
||||||
import { PluginAPI } from "./plugin"
|
import { PluginAPI } from "./plugin"
|
||||||
import { WebhookAPI } from "./webhook"
|
import { WebhookAPI } from "./webhook"
|
||||||
|
import { EnvironmentAPI } from "./environment"
|
||||||
|
import { UserPublicAPI } from "./public/user"
|
||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
application: ApplicationAPI
|
application: ApplicationAPI
|
||||||
|
@ -24,6 +26,7 @@ export default class API {
|
||||||
automation: AutomationAPI
|
automation: AutomationAPI
|
||||||
backup: BackupAPI
|
backup: BackupAPI
|
||||||
datasource: DatasourceAPI
|
datasource: DatasourceAPI
|
||||||
|
environment: EnvironmentAPI
|
||||||
legacyView: LegacyViewAPI
|
legacyView: LegacyViewAPI
|
||||||
permission: PermissionAPI
|
permission: PermissionAPI
|
||||||
plugin: PluginAPI
|
plugin: PluginAPI
|
||||||
|
@ -38,12 +41,17 @@ export default class API {
|
||||||
viewV2: ViewV2API
|
viewV2: ViewV2API
|
||||||
webhook: WebhookAPI
|
webhook: WebhookAPI
|
||||||
|
|
||||||
|
public: {
|
||||||
|
user: UserPublicAPI
|
||||||
|
}
|
||||||
|
|
||||||
constructor(config: TestConfiguration) {
|
constructor(config: TestConfiguration) {
|
||||||
this.application = new ApplicationAPI(config)
|
this.application = new ApplicationAPI(config)
|
||||||
this.attachment = new AttachmentAPI(config)
|
this.attachment = new AttachmentAPI(config)
|
||||||
this.automation = new AutomationAPI(config)
|
this.automation = new AutomationAPI(config)
|
||||||
this.backup = new BackupAPI(config)
|
this.backup = new BackupAPI(config)
|
||||||
this.datasource = new DatasourceAPI(config)
|
this.datasource = new DatasourceAPI(config)
|
||||||
|
this.environment = new EnvironmentAPI(config)
|
||||||
this.legacyView = new LegacyViewAPI(config)
|
this.legacyView = new LegacyViewAPI(config)
|
||||||
this.permission = new PermissionAPI(config)
|
this.permission = new PermissionAPI(config)
|
||||||
this.plugin = new PluginAPI(config)
|
this.plugin = new PluginAPI(config)
|
||||||
|
@ -57,5 +65,8 @@ export default class API {
|
||||||
this.user = new UserAPI(config)
|
this.user = new UserAPI(config)
|
||||||
this.viewV2 = new ViewV2API(config)
|
this.viewV2 = new ViewV2API(config)
|
||||||
this.webhook = new WebhookAPI(config)
|
this.webhook = new WebhookAPI(config)
|
||||||
|
this.public = {
|
||||||
|
user: new UserPublicAPI(config),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<User> => {
|
||||||
|
const response = await this._get<{ data: User }>(`/users/${id}`, {
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
update = async (user: User, expectations?: Expectations): Promise<User> => {
|
||||||
|
const response = await this._put<{ data: User }>(`/users/${user._id}`, {
|
||||||
|
body: user,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy = async (id: string, expectations?: Expectations): Promise<void> => {
|
||||||
|
return await this._delete(`/users/${id}`, { expectations })
|
||||||
|
}
|
||||||
|
|
||||||
|
create = async (
|
||||||
|
user: UnsavedUser,
|
||||||
|
expectations?: Expectations
|
||||||
|
): Promise<User> => {
|
||||||
|
const response = await this._post<{ data: User }>("/users", {
|
||||||
|
body: user,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,6 +37,7 @@ import {
|
||||||
DeepPartial,
|
DeepPartial,
|
||||||
FilterCondition,
|
FilterCondition,
|
||||||
AutomationTriggerResult,
|
AutomationTriggerResult,
|
||||||
|
CreateEnvironmentVariableRequest,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { LoopInput } from "../../definitions/automations"
|
import { LoopInput } from "../../definitions/automations"
|
||||||
import { merge } from "lodash"
|
import { merge } from "lodash"
|
||||||
|
@ -574,7 +575,7 @@ export function basicEnvironmentVariable(
|
||||||
name: string,
|
name: string,
|
||||||
prod: string,
|
prod: string,
|
||||||
dev?: string
|
dev?: string
|
||||||
) {
|
): CreateEnvironmentVariableRequest {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
production: prod,
|
production: prod,
|
||||||
|
|
11
yarn.lock
11
yarn.lock
|
@ -2796,9 +2796,9 @@
|
||||||
through2 "^2.0.0"
|
through2 "^2.0.0"
|
||||||
|
|
||||||
"@budibase/pro@npm:@budibase/pro@latest":
|
"@budibase/pro@npm:@budibase/pro@latest":
|
||||||
version "3.4.20"
|
version "3.4.22"
|
||||||
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.20.tgz#0d855d6ed8fe92fd178c74a8963d879cc124b034"
|
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.22.tgz#943f23cb7056041bc1f433ee60b3d093145e7a4a"
|
||||||
integrity sha512-hUteGvhMOKjBo0fluxcqNs7d4x8OU5W8Oqqrm7eIS9Ohe7ala2iWNCcrj+x+S9CavIm6s7JZZnAewa2Maiz2zQ==
|
integrity sha512-Du3iZsmRLopfoi2SvxQyY1P2Su3Nw0WbITOrKmZFsVLjZ9MzzTZs0Ph/SJHzrfJpM7rn9+8788BLSf3Z3l9KcQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@anthropic-ai/sdk" "^0.27.3"
|
"@anthropic-ai/sdk" "^0.27.3"
|
||||||
"@budibase/backend-core" "*"
|
"@budibase/backend-core" "*"
|
||||||
|
@ -7142,6 +7142,11 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/superagent" "*"
|
"@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":
|
"@types/tar-fs@2.0.1":
|
||||||
version "2.0.1"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-2.0.1.tgz#6391dcad1b03dea2d79fac07371585ab54472bb1"
|
resolved "https://registry.yarnpkg.com/@types/tar-fs/-/tar-fs-2.0.1.tgz#6391dcad1b03dea2d79fac07371585ab54472bb1"
|
||||||
|
|
Loading…
Reference in New Issue