UNPKG

@budibase/server

Version:
1,385 lines (1,224 loc) • 57.8 kB
import { DEFAULT_TABLES } from "../../../db/defaultData/datasource_bb_default" import { USERS_TABLE_SCHEMA } from "../../../constants" import { setEnv, withEnv } from "../../../environment" import { Header, context, db, events, roles } from "@budibase/backend-core" import { structures } from "@budibase/backend-core/tests" import { type Workspace, BuiltinPermissionID, PermissionLevel, Screen, WorkspaceApp, } from "@budibase/types" import nock from "nock" import path from "path" import tk from "timekeeper" import * as uuid from "uuid" import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder" import { WorkspaceStatus } from "../../../db/utils" import env from "../../../environment" import sdk from "../../../sdk" import { getAppObjectStorageEtags } from "../../../tests/utilities/objectStore" import { basicQuery, basicScreen, basicTable, customScreen, } from "../../../tests/utilities/structures" import * as setup from "./utilities" import { checkBuilderEndpoint } from "./utilities/TestFunctions" const generateAppName = () => { return structures.generator.word({ length: 10 }) } describe("/applications", () => { let config = setup.getConfig() let workspace: Workspace afterAll(() => { setup.afterAll() }) beforeAll(async () => { await config.init() }) async function createNewApp() { workspace = await config.newTenant() await config.publish() } beforeEach(async () => { await createNewApp() jest.clearAllMocks() nock.cleanAll() }) // These need to go first for the app totals to make sense describe("permissions", () => { it("should only return apps a user has access to", async () => { let user = await config.createUser({ builder: { global: false }, admin: { global: false }, }) await config.withUser(user, async () => { const apps = await config.api.workspace.fetch() expect(apps).toHaveLength(0) }) user = await config.globalUser({ ...user, builder: { apps: [config.getProdWorkspaceId()], }, }) await config.withUser(user, async () => { const apps = await config.api.workspace.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.workspace.fetch() expect(apps).toHaveLength(0) }) const role = await config.api.roles.save({ name: "Test", inherits: "PUBLIC", permissionId: BuiltinPermissionID.READ_ONLY, version: "name", }) user = await config.globalUser({ ...user, roles: { [config.getProdWorkspaceId()]: role.name, }, }) await config.withUser(user, async () => { const apps = await config.api.workspace.fetch() expect(apps).toHaveLength(1) }) }) it("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.workspace.fetch() expect(apps).toHaveLength(0) }) const roleName = uuid.v4().replace(/-/g, "") const role = await config.api.roles.save({ name: roleName, inherits: "PUBLIC", permissionId: BuiltinPermissionID.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.workspace.fetch() expect(apps).toHaveLength(1) }) }) }) describe("create", () => { const checkScreenCount = async (expectedCount: number) => { const res = await config.api.workspace.getDefinition( config.getDevWorkspaceId() ) expect(res.screens.length).toEqual(expectedCount) } const checkTableCount = async (expectedCount: number) => { const tables = await config.api.table.fetch() expect(tables.length).toEqual(expectedCount) } it("creates empty app without sample data", async () => { const name = generateAppName() const newWorkspace = await config.api.workspace.create({ name, }) expect(newWorkspace.name).toBe(name) expect(newWorkspace._id).toBeDefined() expect(events.app.created).toHaveBeenCalledTimes(1) // Ensure we created a blank app without sample data await checkScreenCount(0) await checkTableCount(1) // users table }) it("adds the workspace creator to the dev users table", async () => { const creator = config.getUser() const newWorkspace = await config.api.workspace.create({ name: generateAppName(), }) await config.withApp(newWorkspace, async () => { const rows = await config.api.row.fetch(USERS_TABLE_SCHEMA._id!) const userRow = rows.find(row => row.email === creator.email) expect(userRow).toBeDefined() expect(userRow?.roleId).toEqual(roles.BUILTIN_ROLE_IDS.ADMIN) }) }) it("creates app with sample data when onboarding", async () => { const name = "Welcome app" const newWorkspace = await config.api.workspace.create({ name, isOnboarding: "true", }) expect(newWorkspace._id).toBeDefined() expect(newWorkspace.name).toBe("Default workspace") expect(events.app.created).toHaveBeenCalledTimes(1) // Check sample resources in the newly created app context await config.withApp(newWorkspace, async () => { const workspaceAppsFetchResult = await config.api.workspaceApp.fetch() const { workspaceApps: [app], } = workspaceAppsFetchResult expect(app.name).toBe(name) const res = await config.api.workspace.getDefinition(newWorkspace.appId) expect(res.screens.length).toEqual(1) const tables = await config.api.table.fetch() expect(tables.length).toEqual(5) }) }) it("creates app from template", async () => { nock("https://prod-budi-templates.s3-eu-west-1.amazonaws.com") .get(`/templates/app/expense-approval.tar.gz`) .replyWithFile( 200, path.resolve(__dirname, "data", "expense-approval.tar.gz") ) const newApp = await config.api.workspace.create({ name: generateAppName(), useTemplate: "true", templateKey: "app/expense-approval", }) expect(newApp._id).toBeDefined() expect(events.app.created).toHaveBeenCalledTimes(1) expect(events.app.templateImported).toHaveBeenCalledTimes(1) // Check resources from template in the newly created app context await config.withApp(newApp, async () => { const res = await config.api.workspace.getDefinition(newApp.appId) expect(res.screens.length).toEqual(6) const tables = await config.api.table.fetch() expect(tables.length).toEqual(4) }) }) it("creates app from file", async () => { const newApp = await config.api.workspace.create({ name: generateAppName(), useTemplate: "true", fileToImport: "src/api/routes/tests/data/old-app.txt", // export.tx was empty }) expect(newApp._id).toBeDefined() expect(events.app.created).toHaveBeenCalledTimes(1) expect(events.app.fileImported).toHaveBeenCalledTimes(1) // Check resources from import file in the newly created app context await config.withApp(newApp, async () => { const res = await config.api.workspace.getDefinition(newApp.appId) expect(res.screens.length).toEqual(1) const tables = await config.api.table.fetch() expect(tables.length).toEqual(1) }) }) it("should apply authorization to endpoint", async () => { await checkBuilderEndpoint({ config, method: "POST", url: `/api/applications`, body: { name: "My App" }, }) }) it("migrates navigation settings from old apps", async () => { const app = await config.api.workspace.create({ name: generateAppName(), useTemplate: "true", fileToImport: "src/api/routes/tests/data/old-app.txt", }) expect(app._id).toBeDefined() expect(app.navigation).toBeDefined() expect(app.navigation!.hideLogo).toBe(true) expect(app.navigation!.title).toBe("Custom Title") expect(app.navigation!.hideLogo).toBe(true) expect(app.navigation!.navigation).toBe("Left") expect(app.navigation!.navBackground).toBe( "var(--spectrum-global-color-blue-600)" ) expect(app.navigation!.navTextColor).toBe( "var(--spectrum-global-color-gray-50)" ) expect(events.app.created).toHaveBeenCalledTimes(1) expect(events.app.fileImported).toHaveBeenCalledTimes(1) }) it("should reject with a known name", async () => { await config.api.workspace.create( { name: workspace.name }, { body: { message: "Workspace name is already in use." }, status: 400 } ) }) it("should reject with a known url", async () => { await config.api.workspace.create( { name: "made up", url: workspace!.url! }, { body: { message: "App URL is already in use." }, status: 400 } ) }) it("creates app from a old import", async () => { const newApp = await config.api.workspace.createFromImport({ name: generateAppName(), fileToImport: path.join(__dirname, "data", "old-export.enc.tar.gz"), encryptionPassword: "testtest", }) expect(newApp._id).toBeDefined() expect(events.app.created).toHaveBeenCalledTimes(1) expect(events.app.fileImported).toHaveBeenCalledTimes(1) // Check resources from import file in the newly created app context await config.withApp(newApp, async () => { const res = await config.api.workspace.getDefinition(newApp.appId) expect(res.screens.length).toEqual(2) const tables = await config.api.table.fetch() expect(tables.length).toEqual(7) }) const fileEtags = await getAppObjectStorageEtags(newApp.appId) expect(fileEtags).toEqual({ // These etags match the ones from the export file "budibase-client.js": "a0ab956601262aae131122b3f65102da-2", "manifest.json": "8eecdd3935062de5298d8d115453e124", }) }) it("creates app from a new import", async () => { const newApp = await config.api.workspace.createFromImport({ name: generateAppName(), fileToImport: path.join( __dirname, "data", "export-change-request.tar.gz" ), }) expect(newApp._id).toBeDefined() expect(events.app.created).toHaveBeenCalledTimes(1) expect(events.app.fileImported).toHaveBeenCalledTimes(1) // Check resources from import file in the newly created app context await config.withApp(newApp, async () => { const res = await config.api.workspace.getDefinition(newApp.appId) expect(res.screens.length).toEqual(6) const tables = await config.api.table.fetch() expect(tables.length).toEqual(3) }) const fileEtags = await getAppObjectStorageEtags(newApp.appId) expect(fileEtags).toEqual({ // These etags match the ones from the export file "budibase-client.js": "e5cc573e15b6f763059fb39c7023563b", "chunks/Accordion-2cb8cb47.js": "6c31abff08901e08cbccddb166c45595", "chunks/ApexChart-39eab0fb.js": "6c3a81927d09960cd43928a7bcf47494", "chunks/AreaChart-a379ae5f.js": "a3d0bf7077ab88f212f27f70319d7970", "chunks/AttachmentField-e5117cd3.js": "28d5375a8aadcd4a0e2bfaddec5bd101", "chunks/AttachmentSingleField-1572398b.js": "09f744333db6e9089d5d6b4bea88682f", "chunks/BBReferenceField-e3676b66.js": "f7246829444474fddf1b8e9ed3652e02", "chunks/BBReferenceSingleField-fc28c308.js": "8cc1698452229d6963b636d59a3b90f2", "chunks/BackgroundImage-3795f230.js": "b613be4eec5d861ae39a18e81e1948d8", "chunks/BarChart-162a85a3.js": "e447d8bb5974487e6da1c468e17f314f", "chunks/BigIntField-1178528a.js": "095b3580d930300f1b481bc3f91fc0d2", "chunks/BooleanField-a73bf955.js": "91cf7ace226a5370744de046cc6358da", "chunks/Button-55cd96fd.js": "c2ac4b3fdb85e768eab38763023ea5a7", "chunks/ButtonGroup-dd32cbc5.js": "70f98d27c6e4a3d81c799781db11fb1b", "chunks/CandleStickChart-c8debfdc.js": "70adf3c3955f6a2a08d2f347d6a801c4", "chunks/Card-0f6d1d51.js": "0bf5032569513ca6d2a6cd81cda6ef27", "chunks/CardHorizontal-e6ec1d90.js": "0ec2cb7147611937b00985ae75d1f12d", "chunks/CardStat-10bc4b91.js": "289581a2f425e4bf6cf2118e51bbefa2", "chunks/CardsBlock-3bf33a86.js": "15c4f90da5021530d2f3bcfcd16184dd", "chunks/ChartBlock-9712464a.js": "e42adb87fc2d53b4c88f3831e32d437b", "chunks/CheckboxGroup-995bb449.js": "d234f50e0ef9fcbeb212a82cdf1b8222", "chunks/CodeGenerator-491374a6.js": "5ab08a313fd009d5c3ccfae4294a01d2", "chunks/CodeScannerField-878f0124.js": "880ab344a303f85d245d0188936cbaa8", "chunks/CollapsedButtonGroup-2dfcf354.js": "8a8ab48ee3e3cb65c7ada31d7f6fe2b5", "chunks/Container-7ddcd183.js": "d1983447bf76acd38d89fa131ae77b11", "chunks/DataProvider-2dab392d.js": "94ea5723bd485131e8aec71991311252", "chunks/DatePicker-1e1fb6b9.js": "2710176a8f77f123bc5e2a24d9d579b8", "chunks/DatePicker-7555af0b.js": "cfe62110215bc4e76805939032b7f0e2", "chunks/DateRangePicker-a1795a88.js": "85ef887f3f29e68f888ecc7d2b9cba48", "chunks/DateTimeField-f5d99d23.js": "b6489bc9d0e90f56314fcf27a4754464", "chunks/Divider-68332217.js": "611627e6f84349e36ecefbb108455348", "chunks/DonutChart-28fb127b.js": "be3e0af33c16f5c3d581af02478b7e33", "chunks/DynamicFilter-dbdc356f.js": "89e8e20238b69e0fbbb227caba631681", "chunks/Embed-dc9f82d1.js": "96d4019d86360bc3542a9d5e93422640", "chunks/EmbeddedMap-6e3f96cb.js": "1579f6ab323805cfde554412c2d446a7", "chunks/Field-026e9b04.js": "3a908b104e0034c0154ab03b44f7477b", "chunks/FieldGroup-f0c1e7cf.js": "d606e090ec0b738b8b6eb01d95a6098f", "chunks/Filter-3a9aa189.js": "7276af9ac5a0bbdeeafef42ce14fda5e", "chunks/Form-56b35759.js": "9e2e769ace0f80bb439dd301ff863d73", "chunks/FormBlock-3cd3ad17.js": "1e19c090b5823dd18c1be05dc6ab4f81", "chunks/FormBlockComponent-2878d220.js": "1a07a0d8ae66a2105aff65b32f8a530e", "chunks/FormStep-df7eab03.js": "a768c847b0deabaa1d77f7067bef8a03", "chunks/GridBlock-491c18a4.js": "90f1ef4741f7376faed56a409961cfac", "chunks/Heading-4b1671cd.js": "ac4638c1ae5f62309116512a8e0eab51", "chunks/HistogramChart-203aa0f3.js": "a9642f7caf35c1337b5e9395949fa243", "chunks/Icon-fd04df96.js": "ffc96f184a714cb2f2f1b5e85c660f13", "chunks/IconV2-ad7b0c23.js": "d07f14e49be8cbb09a3abbde6cf0a48f", "chunks/Image-3a087865.js": "062618e193044c72278a3151a589f012", "chunks/InnerForm-c723d600.js": "19928ce1f80cbd367844b913d42eec5c", "chunks/Item-9d93f702.js": "6fa24583053078ef61d00c78708f8c21", "chunks/JSONField-7e954e3d.js": "5b24c14e5538fcc9df5f18805818dda0", "chunks/Layout-40e3c850.js": "0e20d870ea11b7dd0dab3c261816de1c", "chunks/LineChart-068e963f.js": "1e6adf36b182c445443aec22eb88be96", "chunks/Link-4880dae1.js": "d27c1db45a61e1dd5b698b57dc739bfb", "chunks/LongFormField-bc02d88c.js": "68a94ff7ed3c6d0216336ae0462a1f59", "chunks/MarkdownViewer-7a4fbc38.js": "63317b70e8e7da3842ddd0f07ab234f4", "chunks/MarkdownViewer-dd1a3360.js": "ee22472560b67ae10f0f30c4ab32dddc", "chunks/Modal-008cf67c.js": "b29d0060369155dcd334d1b68cbba03e", "chunks/MultiFieldSelect-23df1bf3.js": "276080e77efbc46058b53339da0641fd", "chunks/MultiStepFormblock-01bfdb43.js": "431f0cfdccddb7319d5906a776d4ec6d", "chunks/Multiselect-7c6c2576.js": "ea398ee03e5c7a1007f1276423ff5daa", "chunks/Navigation-87988fa7.js": "b5c4a52e7abed3e0efcc82fc5c3aa2c3", "chunks/NumberField-466f5c60.js": "f5e6ff725f6eb731756f9f8db70a10ad", "chunks/OptionsField-b06d8158.js": "17ecda7500b7f547566e2cc465a24446", "chunks/PDF-02c0256e.js": "3a4cff05d9f3e8d472ee10e7ef1b0667", "chunks/PDFTable-dfb99b1f.js": "543210d354116900a6df607ac499d33c", "chunks/PasswordField-c9480ea8.js": "fb3b3624295b0fc4824d9a65555c9942", "chunks/PieChart-da6edd10.js": "3df5a1faf78a0a7abd282eddab1176fe", "chunks/Placeholder-31706623.js": "60d9ea517a314f0236b2ad695f3a7bbc", "chunks/RadioGroup-00f609dd.js": "23279784bec23c9ce9fcea24399647ff", "chunks/RatingField-ffd5a256.js": "7a4451771f5d215384b1f48feb535872", "chunks/RelationshipField-1b2fe5d7.js": "188c395de77004c4f5fb92f96b4b12a6", "chunks/Repeater-81cb2810.js": "954d0a4355b87ca3cb3c4f7cba42cd93", "chunks/RepeaterBlock-fb4c50d5.js": "41ab68b67e9ba2ab941dd38b6b2f94c7", "chunks/RowExplorer-33d5e611.js": "df6b48cd8f3f7788952da92e3708e0dc", "chunks/S3Upload-3385d6dc.js": "266f1f5d2189649681c3488c1d9605e6", "chunks/ScreenSlot-68962710.js": "6decb6e4e44a2a49bde5cd5f27155236", "chunks/Section-9872aa71.js": "f3670bfbc30ee6077e2171d52fd608d9", "chunks/SidePanel-cd5a291c.js": "92c3b80b1c39b46d3318ca7456a18eec", "chunks/SignatureField-7558b68b.js": "ff7f93e792465217d6c85a19bff6e402", "chunks/SingleRowProvider-63b76c66.js": "9032c0b573ff818cea22d24689f08f32", "chunks/SpectrumCard-a5a528eb.js": "31a0c889a4106db57c2817a86c8acfa4", "chunks/StackedList-4cc5f377.js": "a71b5816fd410a805a77a19cf42ea6ca", "chunks/StringField-126698b8.js": "7eb28a84da15ebffbc86b7d3a63b7f53", "chunks/Table-587c58c0.js": "7fde88b76de65d315ab68eb596ad41e6", "chunks/TableBlock-38f80d18.js": "7ea2724d05a25dfc9dca4ee52259f042", "chunks/Tag-878e0f24.js": "0c9b9080278e99f99988b1a867c1ba39", "chunks/Text-82e2f22f.js": "861dd585b7eb13d9ce95e96642c855f0", "chunks/Text-c9a1f90b.js": "0fafd70680293ea5b37956a009947d73", "chunks/TextArea-7aa2423b.js": "0b49472f219125cf3deac7ae06ad298c", "chunks/UserAvatar-be3a991a.js": "9531b40b5486a62146b4a40bad3a03a9", "chunks/___vite-browser-external_commonjs-proxy-7d128c64.js": "0f39b111899f167f217ce463140097d4", "chunks/_commonjs-dynamic-modules-58f2b0ec.js": "f4894f5027d4507efa95bac3f0835734", "chunks/apexcharts.common-4a420431.js": "7725a9d63f9bde736d08c554fc3138b9", "chunks/blocks-37916d2a.js": "d885dee62719debeca88185734e803e3", "chunks/easymde-4c022f51.js": "bbf6dc7af9d62fdad24ddfb56685dd0d", "chunks/index-445f15a6.js": "9e163d2006e7c7f9245dc849d3ac82a5", "chunks/index-a0738cd3.js": "b9b4e1ccd9dd74a91b632bfae8dff028", "chunks/optionsParser-a13cad91.js": "6dc97a247da4216a7706cfa641a8c94f", "chunks/phosphorIconLoader-f5abc73c.js": "b2ab8b782be3a65902f50a4f5bcb079b", "chunks/table-a8827bda.js": "83edddafbee024f0efa03eb3097931e4", "chunks/users-c42bb877.js": "76b0eaaf02626699bb7140acfc5cf688", "chunks/utc-ac6b2ab4.js": "d8ad2d7735b971be7749d7f62e81629c", "manifest.json": "93bcfcf77d7c3fa555642816f42214fa", }) }) it("preserves app scripts when creating from an import", async () => { const sourceWorkspace = await config.api.workspace.create({ name: generateAppName(), }) const scripts = [ { id: "s1", name: "Head script", location: "Head" as const, html: "<script>window.__testHead = true</script>", cspWhitelist: "https://example.com", }, { id: "s2", name: "Body script", location: "Body" as const, html: "<script>window.__testBody = true</script>", }, ] await config.withApp(sourceWorkspace, async () => { await config.api.workspace.update(sourceWorkspace.appId, { scripts }) }) const exportPath = await sdk.backups.exportApp(sourceWorkspace.appId, { tar: true, }) const newWorkspace = await config.api.workspace.createFromImport({ name: generateAppName(), fileToImport: exportPath, }) const workspacePackage = await config.withApp(newWorkspace, async () => { return await config.api.workspace.getAppPackage(newWorkspace.appId) }) const importedScripts = workspacePackage.application.scripts || [] expect(importedScripts).toHaveLength(scripts.length) const headScript = importedScripts.find(s => s.location === "Head") expect(headScript).toMatchObject({ name: "Head script", location: "Head", html: "<script>window.__testHead = true</script>", cspWhitelist: "https://example.com", }) const bodyScript = importedScripts.find(s => s.location === "Body") expect(bodyScript).toMatchObject({ name: "Body script", location: "Body", html: "<script>window.__testBody = true</script>", }) }) }) describe("fetch", () => { it("lists all applications", async () => { const apps = await config.api.workspace.fetch({ status: WorkspaceStatus.DEV, }) expect(apps.length).toBeGreaterThan(0) }) }) describe("fetchClientApps", () => { it("should return apps when workspace app are published", async () => { const response = await config.api.workspace.fetchClientApps() expect(response.apps).toHaveLength(1) expect(response.apps[0]).toEqual( expect.objectContaining({ prodId: config.getProdWorkspaceId(), url: workspace.url, }) ) }) it("should return multiple apps when published app with workspace apps exists", async () => { await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "Test Workspace App", url: "/testapp", }) ) await config.publish() const response = await config.api.workspace.fetchClientApps() expect(response.apps.length).toBe(2) const testApp = response.apps.find(a => a.name === "Test Workspace App") expect(testApp).toEqual( expect.objectContaining({ prodId: config.getProdWorkspaceId(), name: "Test Workspace App", url: `${workspace.url}/testapp`, }) ) }) it("should handle creating multiple workspace apps", async () => { const { workspaceApp: workspaceApp1 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "App One", url: "/appone", }) ) const { workspaceApp: workspaceApp2 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "App Two", url: "/apptwo", }) ) const app = await config.publish() const response = await config.api.workspace.fetchClientApps() expect(response.apps.length).toBe(3) expect(response.apps).toEqual( expect.arrayContaining([ { appId: expect.stringMatching( new RegExp(`^${app.appId}_workspace_app_.+`) ), name: app.name, prodId: app.appId, updatedAt: app.updatedAt, url: app.url, }, { appId: `${app.appId}_${workspaceApp1._id}`, name: "App One", prodId: config.getProdWorkspaceId(), updatedAt: app.updatedAt, url: `${app.url}/appone`, }, { appId: `${app.appId}_${workspaceApp2._id}`, name: "App Two", prodId: config.getProdWorkspaceId(), updatedAt: app.updatedAt, url: `${app.url}/apptwo`, }, ]) ) }) it("should return apps from multiple published workspaces", async () => { const { workspaceApp: app1Workspace1 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "App One", url: "/appone", }) ) workspace = await config.publish() const secondWorkspace = await tk.withFreeze(new Date(), async () => { // Create second workspace let secondWorkspace = await config.api.workspace.create({ name: "Second workspace", url: "workspace2", }) await config.withApp(secondWorkspace, async () => { await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "App Two", url: "/apptwo", disabled: false, }) ) await config.api.workspace.publish(secondWorkspace.appId) }) return secondWorkspace }) const response = await config.api.workspace.fetchClientApps() expect(response.apps).toHaveLength(3) expect(response.apps).toEqual( expect.arrayContaining([ { appId: expect.stringMatching( new RegExp(`^${workspace.appId}_workspace_app_.+`) ), name: workspace.name, prodId: workspace.appId, updatedAt: workspace.updatedAt, url: workspace.url, }, { appId: `${workspace.appId}_${app1Workspace1._id}`, name: "App One", prodId: config.getProdWorkspaceId(), updatedAt: workspace.updatedAt, url: `${workspace.url}/appone`, }, { appId: expect.stringMatching( new RegExp( `^${db.getProdWorkspaceID(secondWorkspace.appId)}_workspace_app_.+` ) ), name: "App Two", prodId: db.getProdWorkspaceID(secondWorkspace.appId), updatedAt: secondWorkspace.updatedAt, url: `${secondWorkspace.url}/apptwo`, }, ]) ) }) it("should not return unpublished workspaces", async () => { const { workspaceApp: app1Workspace1 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "App One", url: "/appone", disabled: false, }) ) workspace = await config.publish() // Non published workspace await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "Another workspace", url: "/other", disabled: false, }) ) // Create second app const secondWorkspace = await tk.withFreeze(new Date(), async () => { const secondWorkspace = await config.api.workspace.create({ name: "Second workspace", }) await config.withApp(secondWorkspace, () => config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "Default", url: "/", }) ) ) await config.api.workspace.publish(secondWorkspace.appId) return secondWorkspace }) // Unpublished workspace const thirdWorkspace = await config.api.workspace.create({ name: "Third App", }) await config.withApp(thirdWorkspace, () => config.api.workspaceApp.create(structures.workspaceApps.createRequest()) ) const response = await config.api.workspace.fetchClientApps() expect(response.apps).toHaveLength(3) expect(response.apps).toEqual( expect.arrayContaining([ { appId: expect.stringMatching( new RegExp(`^${workspace.appId}_workspace_app_.+`) ), name: workspace.name, prodId: workspace.appId, updatedAt: workspace.updatedAt, url: workspace.url, }, { appId: `${workspace.appId}_${app1Workspace1._id}`, name: "App One", prodId: config.getProdWorkspaceId(), updatedAt: workspace.updatedAt, url: `${workspace.url}/appone`, }, { appId: expect.stringMatching( new RegExp( `^${db.getProdWorkspaceID(secondWorkspace.appId)}_workspace_app_.+` ) ), name: "Default", prodId: db.getProdWorkspaceID(secondWorkspace.appId), updatedAt: secondWorkspace.updatedAt, url: secondWorkspace.url, }, ]) ) }) it("should not return disabled apps", async () => { const { workspaceApp: app1Workspace1 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "App One", url: "/appone", disabled: false, }) ) await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "Another app", url: "/other", disabled: true, }) ) workspace = await config.publish() const response = await config.api.workspace.fetchClientApps() expect(response.apps).toHaveLength(2) expect(response.apps).toEqual( expect.arrayContaining([ { appId: expect.stringMatching( new RegExp(`^${workspace.appId}_workspace_app_.+`) ), name: workspace.name, prodId: workspace.appId, updatedAt: workspace.updatedAt, url: workspace.url, }, { appId: `${workspace.appId}_${app1Workspace1._id}`, name: "App One", prodId: config.getProdWorkspaceId(), updatedAt: workspace.updatedAt, url: `${workspace.url}/appone`, }, ]) ) }) }) describe("fetchAppDefinition", () => { it("should be able to get an apps definition", async () => { const res = await config.api.workspace.getDefinition(workspace.appId) expect(res.libraries.length).toEqual(1) }) }) describe("fetchAppPackage", () => { it("should be able to fetch the app package", async () => { const res = await config.api.workspace.getAppPackage(workspace.appId) expect(res.application).toBeDefined() expect(res.application.appId).toEqual(config.getDevWorkspaceId()) }) it("should retrieve all the screens for builder calls", async () => { await config.api.screen.save(basicScreen()) await config.api.screen.save(basicScreen()) await config.api.screen.save(basicScreen()) const res = await config.api.workspace.getAppPackage(workspace.appId) expect(res.screens).toHaveLength(3) // 3 created screens }) it("should retrieve all the screens for public calls", async () => { const [_screen1, screen2, _screen3] = await Promise.all([ config.api.screen.save(basicScreen()), config.api.screen.save( customScreen({ roleId: roles.BUILTIN_ROLE_IDS.PUBLIC, route: "/", }) ), config.api.screen.save(basicScreen()), ]) await config.publish() const res = await config.withHeaders( { referer: `http://localhost:10000/app${workspace.url}` }, () => config.api.workspace.getAppPackage(config.getProdWorkspaceId(), { publicUser: true, }) ) expect(res.screens).toHaveLength(1) expect(res.screens).toContainEqual( expect.objectContaining({ _id: screen2._id }) ) }) it("should allow users in multiple groups with different roles to access all permitted screens", async () => { const hrRole = await config.api.roles.save({ name: `HR_${structures.generator.guid().replace(/[^a-zA-Z0-9]/g, "")}`, inherits: [roles.BUILTIN_ROLE_IDS.BASIC], permissionId: BuiltinPermissionID.WRITE, version: "name", }) const expensesScreen = await config.api.screen.save( customScreen({ roleId: roles.BUILTIN_ROLE_IDS.BASIC, route: "/expenses", }) ) const employeesScreen = await config.api.screen.save( customScreen({ roleId: hrRole._id!, route: "/employees", }) ) await config.publish() const appUserGroup = await config.createGroup( roles.BUILTIN_ROLE_IDS.BASIC ) const hrGroup = await config.createGroup(hrRole._id!) const groupUser = await config.createUser({ builder: { global: false }, admin: { global: false }, roles: {}, }) await config.addUserToGroup(appUserGroup._id!, groupUser._id!) await config.addUserToGroup(hrGroup._id!, groupUser._id!) await config.withUser(groupUser, async () => { await config.withHeaders( { referer: `http://localhost:10000/app${workspace.url}` }, async () => { const res = await config.api.workspace.getAppPackage( config.getProdWorkspaceId(), { useProdApp: true, } ) const routes = res.screens.map(screen => screen.routing.route) expect(routes).toEqual( expect.arrayContaining(["/expenses", "/employees"]) ) expect(res.screens).toEqual( expect.arrayContaining([ expect.objectContaining({ _id: expensesScreen._id }), expect.objectContaining({ _id: employeesScreen._id }), ]) ) } ) }) }) describe("workspace apps", () => { it("should retrieve all the screens for builder calls", async () => { await config.api.workspaceApp.create( structures.workspaceApps.createRequest() ) const res = await config.api.workspace.getAppPackage(workspace.appId) expect(res.screens).toHaveLength(0) }) describe("should retrieve only the screens for a given workspace app", () => { let workspaceAppInfo: { workspaceApp: WorkspaceApp screens: Screen[] }[] beforeEach(async () => { const appPackage = await config.api.workspace.getAppPackage( workspace.appId ) let defaultWorkspaceApp: WorkspaceApp | undefined const { workspaceApps: allWorkspaceApps } = await config.api.workspaceApp.fetch() defaultWorkspaceApp = allWorkspaceApps[0] if (!defaultWorkspaceApp) { defaultWorkspaceApp = ( await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ name: "Default", url: "/", }) ) ).workspaceApp } const { workspaceApp: workspaceApp1 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ url: "/app1", }) ) const { workspaceApp: workspaceApp2 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest({ url: "/app2", }) ) workspaceAppInfo = [] async function createScreens( workspaceApp: WorkspaceApp, routes: string[] ) { const screens = [] for (const route of routes) { const screen = await config.api.screen.save({ ...basicScreen(route), workspaceAppId: workspaceApp._id!, }) screens.push(screen) } workspaceAppInfo.push({ workspaceApp, screens, }) } await createScreens(defaultWorkspaceApp, ["/page-1"]) await createScreens(workspaceApp1, ["/", "/page-1", "/page-2"]) await createScreens(workspaceApp2, ["/", "/page-1"]) workspaceAppInfo[0].screens.unshift(...appPackage.screens) }) it.each(["", "/"])( "should retrieve only the screens for a the workspace all with empty prefix", async closingChar => { await config.withHeaders( { referer: `http://localhost:10000/${config.devWorkspaceId}${closingChar}`, }, async () => { const res = await config.api.workspace.getAppPackage( workspace.appId, { headers: { [Header.TYPE]: "client", }, } ) expect(res.screens).toHaveLength(1) expect(res.screens).toEqual( expect.arrayContaining( workspaceAppInfo[0].screens.map(s => expect.objectContaining({ _id: s._id }) ) ) ) } ) } ) it.each(["", "/"])( "should retrieve only the screens for a the workspace from the base url of it", async closingChar => { const { url } = workspaceAppInfo[1].workspaceApp await config.withHeaders( { referer: `http://localhost:10000/${config.devWorkspaceId}${url}${closingChar}`, }, async () => { const res = await config.api.workspace.getAppPackage( workspace.appId, { headers: { [Header.TYPE]: "client", }, } ) expect(res.screens).toHaveLength(3) expect(res.screens).toEqual( expect.arrayContaining( workspaceAppInfo[1].screens.map(s => expect.objectContaining({ _id: s._id }) ) ) ) } ) } ) it("should retrieve only the screens for a the workspace from a page url", async () => { const { url } = workspaceAppInfo[1].workspaceApp await config.withHeaders( { referer: `http://localhost:10000/${config.devWorkspaceId}${url}#page-1`, }, async () => { const res = await config.api.workspace.getAppPackage( workspace.appId, { headers: { [Header.TYPE]: "client", }, } ) expect(res.screens).toHaveLength(3) expect(res.screens).toEqual( expect.arrayContaining( workspaceAppInfo[1].screens.map(s => expect.objectContaining({ _id: s._id }) ) ) ) } ) }) it("should retrieve only the screens for a the workspace for prod app", async () => { await config.publish() await config.withProdApp(() => config.withHeaders( { referer: `http://localhost:10000/app${config.prodWorkspace?.url}`, }, async () => { const res = await config.api.workspace.getAppPackage( config.getDevWorkspaceId(), { headers: { [Header.TYPE]: "client", }, } ) expect(res.screens).toHaveLength(1) expect(res.screens).toEqual( expect.arrayContaining( workspaceAppInfo[0].screens.map(s => expect.objectContaining({ _id: s._id }) ) ) ) } ) ) }) }) }) }) describe("update", () => { it("should be able to update the app package", async () => { const updatedApp = await config.api.workspace.update(workspace.appId, { name: "TEST_APP", }) expect(updatedApp._rev).toBeDefined() expect(events.app.updated).toHaveBeenCalledTimes(1) }) }) describe("publish", () => { it("should publish app with dev app ID", async () => { await config.api.workspace.publish(workspace.appId) expect(events.app.published).toHaveBeenCalledTimes(1) }) it("should publish app with prod app ID", async () => { await config.api.workspace.publish(workspace.appId.replace("_dev", "")) expect(events.app.published).toHaveBeenCalledTimes(1) }) // API to publish filtered resources currently disabled, skip test while not needed it.skip("should publish app with filtered resources, filtering by automation", async () => { // create data resources const table = await config.createTable(basicTable()) // all internal resources are published if any used const tableUnused = await config.createTable(basicTable()) const datasource = await config.createDatasource() const query = await config.createQuery(basicQuery(datasource._id!)) // automation to publish const { automation } = await createAutomationBuilder(config) .onRowSaved({ tableId: table._id! }) .executeQuery({ query: { queryId: query._id! } }) .save() const rowAction = await config.api.rowAction.save(table._id!, { name: "test", }) // create some assets that won't be published const unpublishedDatasource = await config.createDatasource() const { automation: unpublishedAutomation } = await createAutomationBuilder(config) .onRowSaved({ tableId: table._id! }) .save() await config.api.workspace.filteredPublish(workspace.appId, { automationIds: [automation._id!], }) await config.withProdApp(async () => { const { automations } = await config.api.automation.fetch() expect( automations.find(auto => auto._id === automation._id!) ).toBeDefined() expect( automations.find(auto => auto._id === unpublishedAutomation._id!) ).toBeUndefined() // row action automations should be published if row action published expect( automations.find(auto => auto._id === rowAction.automationId) ).toBeDefined() const datasources = await config.api.datasource.fetch() expect(datasources.find(ds => ds._id === datasource._id!)).toBeDefined() expect( datasources.find(ds => ds._id === unpublishedDatasource._id!) ).toBeUndefined() const tables = await config.api.table.fetch() expect(tables.find(tbl => tbl._id === table._id)).toBeDefined() expect(tables.find(tbl => tbl._id === tableUnused._id)).toBeDefined() const { actions } = await config.api.rowAction.find(table._id!) expect( Object.values(actions).find(action => action.id === rowAction.id) ).toBeDefined() }) }) // API to publish filtered resources currently disabled, skip test while not needed it.skip("should publish app with filtered resources, filtering by workspace app", async () => { // create two screens with different workspaceAppIds const { workspaceApp: workspaceApp1 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest() ) const { workspaceApp: workspaceApp2 } = await config.api.workspaceApp.create( structures.workspaceApps.createRequest() ) const publishedScreen = await config.api.screen.save({ ...basicScreen("/published-screen"), workspaceAppId: workspaceApp1._id, name: "published-screen", }) const unpublishedScreen = await config.api.screen.save({ ...basicScreen("/unpublished-screen"), workspaceAppId: workspaceApp2._id, name: "unpublished-screen", }) await config.api.workspace.filteredPublish(workspace.appId, { workspaceAppIds: [workspaceApp1._id], }) await config.withProdApp(async () => { const screens = await config.api.screen.list() // published screen should be included expect( screens.find(screen => screen._id === publishedScreen._id) ).toBeDefined() // unpublished screen should not be included expect( screens.find(screen => screen._id === unpublishedScreen._id) ).toBeUndefined() }) }) it("should publish table permissions for custom roles correctly", async () => { // Create a table for testing permissions const table = await config.api.table.save(basicTable()) expect(table._id).toBeDefined() // Create a custom role const customRole = await config.api.roles.save({ name: "TestRole", inherits: "PUBLIC", permissionId: BuiltinPermissionID.READ_ONLY, version: "name", }) expect(customRole._id).toBeDefined() // Add READ permission for the custom role on the table await config.api.permission.add({ roleId: customRole._id!, resourceId: table._id!, level: PermissionLevel.READ, }) // Verify permissions exist in development const devPermissions = await config.api.permission.get(table._id!) expect(devPermissions.permissions.read.role).toBe(customRole.name) // Publish the application await config.publish() // Verify permissions are correctly published to production await config.withProdApp(async () => { const prodPermissions = await config.api.permission.get(table._id!) expect(prodPermissions.permissions.read.role).toBe(customRole.name) // Also verify the role itself exists in production const roles = await config.api.roles.fetch() const prodRole = roles.find(r => r.name === customRole.name) expect(prodRole).toBeDefined() expect(prodRole!.name).toBe("TestRole") }) }) }) describe("manage client library version", () => { it("should be able to update the app client library version", async () => { await config.api.workspace.updateClient(workspace.appId) expect(events.app.versionUpdated).toHaveBeenCalledTimes(1) }) it("should be able to revert the app client library version", async () => { await config.api.workspace.updateClient(workspace.appId) await config.api.workspace.revertClient(workspace.appId) expect(events.app.versionReverted).toHaveBeenCalledTimes(1) }) }) describe("edited at", () => { it("middleware should set updatedAt", async () => { const app = await tk.withFreeze( "2021-01-01", async () => await config.api.workspace.create({ name: generateAppName() }) ) expect(app.updatedAt).toEqual("2021-01-01T00:00:00.000Z") const updatedApp = await tk.withFreeze( "2021-02-01", async () => await config.withApp(app, () => config.api.workspace.update(app.appId, { name: "UPDATED_NAME", }) ) ) expect(updatedApp._rev).toBeDefined() expect(updatedApp.updatedAt).toEqual("2021-02-01T00:00:00.000Z") const fetchedApp = await config.api.workspace.get(app.appId) expect(fetchedApp.updatedAt).toEqual("2021-02-01T00:00:00.000Z") }) }) describe("sync", () => { it("app should sync correctly", async () => { const { message } = await config.api.workspace.sync(workspace.appId) expect(message).toEqual("App sync completed successfully.") }) it("app should not sync if production", async () => { const { message } = await config.withProdApp(() => config.api.workspace.sync(workspace.appId.replace("_dev", ""), { status: 400, }) ) expect(message).toEqual( "This action cannot be performed for production apps" ) }) it("app should not sync if sync is disabled", async () => { env._set("DISABLE_AUTO_PROD_APP_SYNC", true) const { message } = await config.api.workspace.sync(workspace.appId) expect(message).toEqual( "App sync disabled. You can reenable with the DISABLE_AUTO_PROD_APP_SYNC environment variable." ) env._set("DISABLE_AUTO_PROD_APP_SYNC", false) }) }) describe("unpublish", () => { it("should unpublish app with dev app ID", async () => { await config.api.workspace.unpublish(workspace.appId) expect(events.app.unpublished).toHaveBeenCalledTimes(1) }) it("should unpublish app with prod app ID", async () => {