Merge branch 'master' into enforce-example-com-emails-in-tests

This commit is contained in:
Sam Rose 2024-03-06 17:15:43 +00:00 committed by GitHub
commit 2946188ab1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 374 additions and 125 deletions

View File

@ -6,7 +6,7 @@ import env from "../environment"
import * as accounts from "../accounts"
import { UserDB } from "../users"
import { sdk } from "@budibase/shared-core"
import { User } from "@budibase/types"
import { User, UserMetadata } from "@budibase/types"
const EXPIRY_SECONDS = 3600
@ -15,7 +15,7 @@ const EXPIRY_SECONDS = 3600
*/
async function populateFromDB(userId: string, tenantId: string) {
const db = tenancy.getTenantDB(tenantId)
const user = await db.get<any>(userId)
const user = await db.get<UserMetadata>(userId)
user.budibaseAccess = true
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(user.email)

View File

@ -1,66 +1,57 @@
import PouchDB from "pouchdb"
import { getPouchDB, closePouchDB } from "./couch"
import { DocumentType } from "../constants"
class Replication {
source: any
target: any
replication: any
source: PouchDB.Database
target: PouchDB.Database
/**
*
* @param source - the DB you want to replicate or rollback to
* @param target - the DB you want to replicate to, or rollback from
*/
constructor({ source, target }: any) {
constructor({ source, target }: { source: string; target: string }) {
this.source = getPouchDB(source)
this.target = getPouchDB(target)
}
close() {
return Promise.all([closePouchDB(this.source), closePouchDB(this.target)])
async close() {
await Promise.all([closePouchDB(this.source), closePouchDB(this.target)])
}
promisify(operation: any, opts = {}) {
return new Promise(resolve => {
operation(this.target, opts)
.on("denied", function (err: any) {
replicate(opts: PouchDB.Replication.ReplicateOptions = {}) {
return new Promise<PouchDB.Replication.ReplicationResult<{}>>(resolve => {
this.source.replicate
.to(this.target, opts)
.on("denied", function (err) {
// a document failed to replicate (e.g. due to permissions)
throw new Error(`Denied: Document failed to replicate ${err}`)
})
.on("complete", function (info: any) {
.on("complete", function (info) {
return resolve(info)
})
.on("error", function (err: any) {
.on("error", function (err) {
throw new Error(`Replication Error: ${err}`)
})
})
}
/**
* Two way replication operation, intended to be promise based.
* @param opts - PouchDB replication options
*/
sync(opts = {}) {
this.replication = this.promisify(this.source.sync, opts)
return this.replication
}
appReplicateOpts(
opts: PouchDB.Replication.ReplicateOptions = {}
): PouchDB.Replication.ReplicateOptions {
if (typeof opts.filter === "string") {
return opts
}
/**
* One way replication operation, intended to be promise based.
* @param opts - PouchDB replication options
*/
replicate(opts = {}) {
this.replication = this.promisify(this.source.replicate.to, opts)
return this.replication
}
const filter = opts.filter
delete opts.filter
appReplicateOpts() {
return {
filter: (doc: any) => {
...opts,
filter: (doc: any, params: any) => {
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) {
return false
}
return doc._id !== DocumentType.APP_METADATA
if (doc._id === DocumentType.APP_METADATA) {
return false
}
return filter ? filter(doc, params) : true
},
}
}
@ -75,10 +66,6 @@ class Replication {
// take the opportunity to remove deleted tombstones
await this.replicate()
}
cancel() {
this.replication.cancel()
}
}
export default Replication

View File

@ -101,10 +101,7 @@ export function getBuiltinRole(roleId: string): Role | undefined {
/**
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
*/
export function builtinRoleToNumber(id?: string) {
if (!id) {
return 0
}
export function builtinRoleToNumber(id: string) {
const builtins = getBuiltinRoles()
const MAX = Object.values(builtins).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {

View File

@ -139,10 +139,22 @@
{/each}
</div>
<div class="search-input">
<div class="input-wrapper">
<Input bind:value={searchTerm} thin placeholder="Search Icon" />
<div class="input-wrapper" style={`width: ${value ? "425" : "510"}px`}>
<Input
bind:value={searchTerm}
on:keyup={event => {
if (event.key === "Enter") {
searchForIcon()
}
}}
thin
placeholder="Search Icon"
/>
</div>
<Button secondary on:click={searchForIcon}>Search</Button>
{#if value}
<Button primary on:click={() => (value = null)}>Clear</Button>
{/if}
</div>
<div class="page-area">
<div class="pager">
@ -239,6 +251,7 @@
flex-flow: row nowrap;
width: 100%;
padding-right: 15px;
gap: 10px;
}
.input-wrapper {
width: 510px;

View File

@ -525,6 +525,38 @@
"barTitle": "Disable button",
"key": "disabled"
},
{
"type": "icon",
"label": "Icon",
"key": "icon"
},
{
"type": "select",
"label": "Gap",
"key": "gap",
"showInBar": true,
"barStyle": "picker",
"dependsOn": "icon",
"options": [
{
"label": "None",
"value": "N"
},
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
}
],
"defaultValue": "M"
},
{
"type": "event",
"label": "On click",

View File

@ -13,9 +13,10 @@
export let size = "M"
export let type = "cta"
export let quiet = false
export let icon = null
export let gap = "M"
// For internal use only for now - not defined in the manifest
export let icon = null
export let active = false
const handleOnClick = async () => {
@ -47,7 +48,7 @@
{#key $component.editing}
<button
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`}
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type} gap-${gap}`}
class:spectrum-Button--quiet={quiet}
disabled={disabled || handlingOnClick}
use:styleable={$component.styles}
@ -58,15 +59,7 @@
class:active
>
{#if icon}
<svg
class:hasText={componentText?.length > 0}
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
<i class="{icon} {size}" />
{/if}
{componentText}
</button>
@ -92,4 +85,13 @@
.active {
color: var(--spectrum-global-color-blue-600);
}
.gap-S {
gap: 8px;
}
.gap-M {
gap: 16px;
}
.gap-L {
gap: 32px;
}
</style>

View File

@ -20,7 +20,7 @@
wrap: true,
}}
>
{#each buttons as { text, type, quiet, disabled, onClick, size }}
{#each buttons as { text, type, quiet, disabled, onClick, size, icon, gap }}
<BlockComponent
type="button"
props={{
@ -29,6 +29,8 @@
type,
quiet,
disabled,
icon,
gap,
size: size || "M",
}}
/>

View File

@ -92,9 +92,9 @@
{#if schemaLoaded}
<Button
onClick={openEditor}
icon="Properties"
icon="ri-filter-3-line"
text="Filter"
{size}
size="XL"
type="secondary"
quiet
active={filters?.length > 0}

View File

@ -106,6 +106,21 @@ export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
)
role._rev = result.rev
ctx.body = role
const devDb = context.getDevAppDB()
const prodDb = context.getProdAppDB()
if (await prodDb.exists()) {
const replication = new dbCore.Replication({
source: devDb.name,
target: prodDb.name,
})
await replication.replicate({
filter: (doc: any, params: any) => {
return doc._id && doc._id.startsWith("role_")
},
})
}
}
export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {

View File

@ -1,6 +1,6 @@
import { generateUserFlagID, InternalTables } from "../../db/utils"
import { getFullUser } from "../../utilities/users"
import { context } from "@budibase/backend-core"
import { cache, context } from "@budibase/backend-core"
import {
ContextUserMetadata,
Ctx,

View File

@ -16,8 +16,9 @@ import * as setup from "./utilities"
import { AppStatus } from "../../../db/utils"
import { events, utils, context } from "@budibase/backend-core"
import env from "../../../environment"
import type { App } from "@budibase/types"
import { type App } from "@budibase/types"
import tk from "timekeeper"
import * as uuid from "uuid"
describe("/applications", () => {
let config = setup.getConfig()
@ -251,7 +252,7 @@ describe("/applications", () => {
describe("permissions", () => {
it("should only return apps a user has access to", async () => {
const user = await config.createUser({
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
@ -260,6 +261,81 @@ describe("/applications", () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
user = await config.globalUser({
...user,
builder: {
apps: [config.getProdAppId()],
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it("should only return apps a user has access to through a custom role", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const role = await config.api.roles.save({
name: "Test",
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
user = await config.globalUser({
...user,
roles: {
[config.getProdAppId()]: role.name,
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it.only("should only return apps a user has access to through a custom role on a group", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const roleName = uuid.v4().replace(/-/g, "")
const role = await config.api.roles.save({
name: roleName,
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
const group = await config.createGroup(role._id!)
user = await config.globalUser({
...user,
userGroups: [group._id!],
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
})
})

View File

@ -299,11 +299,11 @@ export default class TestConfiguration {
}
}
withUser(user: User, f: () => Promise<void>) {
async withUser(user: User, f: () => Promise<void>) {
const oldUser = this.user
this.user = user
try {
return f()
return await f()
} finally {
this.user = oldUser
}
@ -363,6 +363,7 @@ export default class TestConfiguration {
_id,
...existing,
...config,
_rev: existing._rev,
email,
roles,
tenantId,
@ -372,11 +373,12 @@ export default class TestConfiguration {
admin,
}
await sessions.createASession(_id, {
sessionId: "sessionid",
sessionId: this.sessionIdForUser(_id),
tenantId: this.getTenantId(),
csrfToken: this.csrfToken,
})
const resp = await db.put(user)
await cache.user.invalidateUser(_id)
return {
_rev: resp.rev,
...user,
@ -384,9 +386,7 @@ export default class TestConfiguration {
}
async createUser(user: Partial<User> = {}): Promise<User> {
const resp = await this.globalUser(user)
await cache.user.invalidateUser(resp._id!)
return resp
return await this.globalUser(user)
}
async createGroup(roleId: string = roles.BUILTIN_ROLE_IDS.BASIC) {
@ -416,6 +416,10 @@ export default class TestConfiguration {
})
}
sessionIdForUser(userId: string): string {
return `sessionid-${userId}`
}
async login({
roleId,
userId,
@ -442,13 +446,13 @@ export default class TestConfiguration {
})
}
await sessions.createASession(userId, {
sessionId: "sessionid",
sessionId: this.sessionIdForUser(userId),
tenantId: this.getTenantId(),
})
// have to fake this
const authObj = {
userId,
sessionId: "sessionid",
sessionId: this.sessionIdForUser(userId),
tenantId: this.getTenantId(),
}
const authToken = jwt.sign(authObj, coreEnv.JWT_SECRET as Secret)
@ -470,7 +474,7 @@ export default class TestConfiguration {
const user = this.getUser()
const authObj: AuthToken = {
userId: user._id!,
sessionId: "sessionid",
sessionId: this.sessionIdForUser(user._id!),
tenantId,
}
const authToken = jwt.sign(authObj, coreEnv.JWT_SECRET as Secret)

View File

@ -11,6 +11,7 @@ import { BackupAPI } from "./backup"
import { AttachmentAPI } from "./attachment"
import { UserAPI } from "./user"
import { QueryAPI } from "./query"
import { RoleAPI } from "./role"
export default class API {
table: TableAPI
@ -25,6 +26,7 @@ export default class API {
attachment: AttachmentAPI
user: UserAPI
query: QueryAPI
roles: RoleAPI
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
@ -39,5 +41,6 @@ export default class API {
this.attachment = new AttachmentAPI(config)
this.user = new UserAPI(config)
this.query = new QueryAPI(config)
this.roles = new RoleAPI(config)
}
}

View File

@ -0,0 +1,41 @@
import {
AccessibleRolesResponse,
FetchRolesResponse,
FindRoleResponse,
SaveRoleRequest,
SaveRoleResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class RoleAPI extends TestAPI {
fetch = async (expectations?: Expectations) => {
return await this._get<FetchRolesResponse>(`/api/roles`, {
expectations,
})
}
find = async (roleId: string, expectations?: Expectations) => {
return await this._get<FindRoleResponse>(`/api/roles/${roleId}`, {
expectations,
})
}
save = async (body: SaveRoleRequest, expectations?: Expectations) => {
return await this._post<SaveRoleResponse>(`/api/roles`, {
body,
expectations,
})
}
destroy = async (roleId: string, expectations?: Expectations) => {
return await this._delete(`/api/roles/${roleId}`, {
expectations,
})
}
accesssible = async (expectations?: Expectations) => {
return await this._get<AccessibleRolesResponse>(`/api/roles/accessible`, {
expectations,
})
}
}

View File

@ -390,24 +390,41 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
}
)
// Match a document against all criteria
const docMatch = (doc: any) => {
return (
stringMatch(doc) &&
fuzzyMatch(doc) &&
rangeMatch(doc) &&
equalMatch(doc) &&
notEqualMatch(doc) &&
emptyMatch(doc) &&
notEmptyMatch(doc) &&
oneOf(doc) &&
contains(doc) &&
containsAny(doc) &&
notContains(doc)
)
}
const filterFunctions: Record<SearchQueryOperators, (doc: any) => boolean> =
{
string: stringMatch,
fuzzy: fuzzyMatch,
range: rangeMatch,
equal: equalMatch,
notEqual: notEqualMatch,
empty: emptyMatch,
notEmpty: notEmptyMatch,
oneOf: oneOf,
contains: contains,
containsAny: containsAny,
notContains: notContains,
}
// Process all docs
const activeFilterKeys: SearchQueryOperators[] = Object.entries(query || {})
.filter(
([key, value]: [string, any]) =>
!["allOr", "onEmptyFilter"].includes(key) &&
value &&
Object.keys(value as Record<string, any>).length > 0
)
.map(([key]) => key as any)
const results: boolean[] = activeFilterKeys.map(filterKey => {
return filterFunctions[filterKey]?.(doc) ?? false
})
if (query!.allOr) {
return results.some(result => result === true)
} else {
return results.every(result => result === true)
}
}
return docs.filter(docMatch)
}

View File

@ -47,10 +47,7 @@ describe("runLuceneQuery", () => {
},
]
function buildQuery(
filterKey: string,
value: { [key: string]: any }
): SearchQuery {
function buildQuery(filters: { [filterKey: string]: any }): SearchQuery {
const query: SearchQuery = {
string: {},
fuzzy: {},
@ -63,8 +60,13 @@ describe("runLuceneQuery", () => {
notContains: {},
oneOf: {},
containsAny: {},
allOr: false,
}
query[filterKey as SearchQueryOperators] = value
for (const filterKey in filters) {
query[filterKey as SearchQueryOperators] = filters[filterKey]
}
return query
}
@ -73,16 +75,17 @@ describe("runLuceneQuery", () => {
})
it("should return matching rows for equal filter", () => {
const query = buildQuery("equal", {
order_status: 4,
const query = buildQuery({
equal: { order_status: 4 },
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1, 2])
})
it("should return matching row for notEqual filter", () => {
const query = buildQuery("notEqual", {
order_status: 4,
const query = buildQuery({
notEqual: { order_status: 4 },
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
})
@ -90,48 +93,56 @@ describe("runLuceneQuery", () => {
expect(
runLuceneQuery(
docs,
buildQuery("fuzzy", {
description: "sm",
buildQuery({
fuzzy: { description: "sm" },
})
).map(row => row.description)
).toEqual(["Small box"])
expect(
runLuceneQuery(
docs,
buildQuery("string", {
description: "SM",
buildQuery({
string: { description: "SM" },
})
).map(row => row.description)
).toEqual(["Small box"])
})
it("should return rows within a range filter", () => {
const query = buildQuery("range", {
customer_id: {
low: 500,
high: 1000,
const query = buildQuery({
range: {
customer_id: {
low: 500,
high: 1000,
},
},
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
})
it("should return rows with numeric strings within a range filter", () => {
const query = buildQuery("range", {
customer_id: {
low: "500",
high: "1000",
const query = buildQuery({
range: {
customer_id: {
low: "500",
high: "1000",
},
},
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
})
it("should return rows with ISO date strings within a range filter", () => {
const query = buildQuery("range", {
order_date: {
low: "2016-01-04T00:00:00.000Z",
high: "2016-01-11T00:00:00.000Z",
const query = buildQuery({
range: {
order_date: {
low: "2016-01-04T00:00:00.000Z",
high: "2016-01-11T00:00:00.000Z",
},
},
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
})
@ -150,40 +161,88 @@ describe("runLuceneQuery", () => {
label: "",
},
]
const query = buildQuery("range", {
order_date: {
low: "2016-01-04T00:00:00.000Z",
high: "2016-01-11T00:00:00.000Z",
const query = buildQuery({
range: {
order_date: {
low: "2016-01-04T00:00:00.000Z",
high: "2016-01-11T00:00:00.000Z",
},
},
})
expect(runLuceneQuery(docs, query)).toEqual(docs)
})
it("should return rows with matches on empty filter", () => {
const query = buildQuery("empty", {
label: null,
const query = buildQuery({
empty: {
label: null,
},
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1])
})
it("should return rows with matches on notEmpty filter", () => {
const query = buildQuery("notEmpty", {
label: null,
const query = buildQuery({
notEmpty: {
label: null,
},
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2, 3])
})
test.each([[523, 259], "523,259"])(
"should return rows with matches on numeric oneOf filter",
input => {
let query = buildQuery("oneOf", {
customer_id: input,
const query = buildQuery({
oneOf: {
customer_id: input,
},
})
expect(runLuceneQuery(docs, query).map(row => row.customer_id)).toEqual([
259, 523,
])
}
)
test.each([
[false, []],
[true, [1, 2, 3]],
])("should return %s if allOr is %s ", (allOr, expectedResult) => {
const query = buildQuery({
allOr,
oneOf: { staff_id: [10] },
contains: { description: ["box"] },
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual(
expectedResult
)
})
it("should return matching results if allOr is true and only one filter matches with different operands", () => {
const query = buildQuery({
allOr: true,
equal: { order_status: 4 },
oneOf: { label: ["FRAGILE"] },
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1, 2])
})
it("should handle when a value is null or undefined", () => {
const query = buildQuery({
allOr: true,
equal: { order_status: null },
oneOf: { label: ["FRAGILE"] },
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
})
})
describe("buildLuceneQuery", () => {

View File

@ -5,4 +5,5 @@ export interface Role extends Document {
inherits?: string
permissions: { [key: string]: string[] }
version?: string
name: string
}