@budibase/worker 3.27.4 → 3.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/worker",
3
3
  "email": "hi@budibase.com",
4
- "version": "3.27.4",
4
+ "version": "3.28.0",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -107,5 +107,5 @@
107
107
  }
108
108
  }
109
109
  },
110
- "gitHead": "d0d77e2d7618885095e00c73cefcfefd8ad54877"
110
+ "gitHead": "b57235144ae6ed94b1b54d9ea73335106feedc02"
111
111
  }
@@ -0,0 +1,77 @@
1
+ import fetch from "node-fetch"
2
+ import { type GetGitHubStarsResponse, type UserCtx } from "@budibase/types"
3
+ import { cache } from "@budibase/backend-core"
4
+
5
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000
6
+ const FAILURE_TTL_MS = 5 * 60 * 1000
7
+ const GITHUB_TIMEOUT_MS = 5000
8
+ const GITHUB_REPO_URL = "https://api.github.com/repos/budibase/budibase"
9
+ const USER_AGENT = "Budibase"
10
+
11
+ const CACHE_KEY = "global:github:stars"
12
+ const RETENTION_TTL_SECONDS = 30 * 24 * 60 * 60
13
+
14
+ interface StarsCacheEnvelope {
15
+ value: GetGitHubStarsResponse
16
+ expiresAt: number
17
+ }
18
+
19
+ export async function getStars(ctx: UserCtx<void, GetGitHubStarsResponse>) {
20
+ const envelope = (await cache.get(CACHE_KEY, {
21
+ useTenancy: false,
22
+ })) as StarsCacheEnvelope | null
23
+ if (envelope && envelope.expiresAt > Date.now()) {
24
+ ctx.body = envelope.value
25
+ return
26
+ }
27
+
28
+ try {
29
+ const response = await fetch(GITHUB_REPO_URL, {
30
+ headers: {
31
+ Accept: "application/vnd.github+json",
32
+ "User-Agent": USER_AGENT,
33
+ },
34
+ timeout: GITHUB_TIMEOUT_MS,
35
+ })
36
+
37
+ if (!response.ok) {
38
+ throw new Error(`GitHub response: ${response.status}`)
39
+ }
40
+
41
+ const json = (await response.json()) as { stargazers_count?: number }
42
+ const stars = json.stargazers_count
43
+
44
+ if (typeof stars !== "number") {
45
+ throw new Error("GitHub stars missing")
46
+ }
47
+
48
+ const value: GetGitHubStarsResponse = {
49
+ stars,
50
+ fetchedAt: new Date().toISOString(),
51
+ }
52
+ const toStore: StarsCacheEnvelope = {
53
+ value,
54
+ expiresAt: Date.now() + CACHE_TTL_MS,
55
+ }
56
+
57
+ await cache.store(CACHE_KEY, toStore, RETENTION_TTL_SECONDS, {
58
+ useTenancy: false,
59
+ })
60
+
61
+ ctx.body = value
62
+ } catch (err) {
63
+ console.error("Failed to fetch GitHub stars", err)
64
+
65
+ const value = envelope?.value || { stars: null, fetchedAt: null }
66
+ const toStore: StarsCacheEnvelope = {
67
+ value,
68
+ expiresAt: Date.now() + FAILURE_TTL_MS,
69
+ }
70
+
71
+ await cache.store(CACHE_KEY, toStore, RETENTION_TTL_SECONDS, {
72
+ useTenancy: false,
73
+ })
74
+
75
+ ctx.body = value
76
+ }
77
+ }
@@ -0,0 +1,193 @@
1
+ import { type GetGitHubStarsResponse } from "@budibase/types"
2
+
3
+ jest.mock("node-fetch", () => jest.fn())
4
+
5
+ interface CacheOpts {
6
+ useTenancy?: boolean
7
+ }
8
+
9
+ interface TestCtx {
10
+ body: GetGitHubStarsResponse | undefined
11
+ }
12
+
13
+ describe("GitHub controller", () => {
14
+ let cacheGet: jest.Mock
15
+ let cacheStore: jest.Mock
16
+ let cacheState: Map<string, unknown>
17
+ let consoleErrorSpy: jest.SpyInstance
18
+
19
+ beforeEach(() => {
20
+ jest.resetModules()
21
+ jest.clearAllMocks()
22
+
23
+ jest.useFakeTimers()
24
+ jest.setSystemTime(new Date("2020-01-01T00:00:00.000Z"))
25
+
26
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
27
+
28
+ cacheState = new Map()
29
+ jest.doMock("@budibase/backend-core", () => {
30
+ cacheGet = jest.fn(async (key: string, opts?: CacheOpts) => {
31
+ if (opts?.useTenancy !== false) {
32
+ throw new Error("Expected global cache usage")
33
+ }
34
+ return cacheState.get(key) ?? null
35
+ })
36
+
37
+ cacheStore = jest.fn(
38
+ async (
39
+ key: string,
40
+ value: unknown,
41
+ _ttl?: number,
42
+ opts?: CacheOpts
43
+ ) => {
44
+ if (opts?.useTenancy !== false) {
45
+ throw new Error("Expected global cache usage")
46
+ }
47
+ cacheState.set(key, value)
48
+ }
49
+ )
50
+
51
+ return {
52
+ cache: {
53
+ get: cacheGet,
54
+ store: cacheStore,
55
+ },
56
+ }
57
+ })
58
+ })
59
+
60
+ afterEach(() => {
61
+ consoleErrorSpy.mockRestore()
62
+ jest.useRealTimers()
63
+ })
64
+
65
+ const getFetchMock = () => {
66
+ return jest.requireMock("node-fetch") as unknown as jest.Mock
67
+ }
68
+
69
+ const makeCtx = () => {
70
+ const ctx: TestCtx = { body: undefined }
71
+ return ctx
72
+ }
73
+
74
+ function assertHasBody(
75
+ ctx: TestCtx
76
+ ): asserts ctx is { body: GetGitHubStarsResponse } {
77
+ if (!ctx.body) {
78
+ throw new Error("Expected ctx.body to be set")
79
+ }
80
+ }
81
+
82
+ it("adds a request timeout", async () => {
83
+ const fetchMock = getFetchMock()
84
+ fetchMock.mockResolvedValue({
85
+ ok: true,
86
+ status: 200,
87
+ json: async () => ({ stargazers_count: 123 }),
88
+ })
89
+
90
+ const { getStars } = require("../github")
91
+ const ctx = makeCtx()
92
+ await getStars(ctx)
93
+
94
+ expect(fetchMock).toHaveBeenCalledTimes(1)
95
+ expect(fetchMock.mock.calls[0][1]).toEqual(
96
+ expect.objectContaining({ timeout: 5000 })
97
+ )
98
+ assertHasBody(ctx)
99
+ expect(ctx.body.stars).toEqual(123)
100
+
101
+ expect(cacheGet).toHaveBeenCalledWith("global:github:stars", {
102
+ useTenancy: false,
103
+ })
104
+ expect(cacheStore).toHaveBeenCalledWith(
105
+ "global:github:stars",
106
+ expect.any(Object),
107
+ expect.any(Number),
108
+ { useTenancy: false }
109
+ )
110
+ })
111
+
112
+ it("caches successful responses", async () => {
113
+ const fetchMock = getFetchMock()
114
+ fetchMock.mockResolvedValue({
115
+ ok: true,
116
+ status: 200,
117
+ json: async () => ({ stargazers_count: 999 }),
118
+ })
119
+
120
+ const { getStars } = require("../github")
121
+ const ctx1 = makeCtx()
122
+ await getStars(ctx1)
123
+ const ctx2 = makeCtx()
124
+ await getStars(ctx2)
125
+
126
+ expect(fetchMock).toHaveBeenCalledTimes(1)
127
+ expect(ctx2.body).toEqual(ctx1.body)
128
+ })
129
+
130
+ it("serves cached value and sets a short failure TTL on error", async () => {
131
+ const fetchMock = getFetchMock()
132
+ fetchMock.mockRejectedValue(new Error("timeout"))
133
+
134
+ const { getStars } = require("../github")
135
+ const ctx1 = makeCtx()
136
+ await getStars(ctx1)
137
+ const ctx2 = makeCtx()
138
+ await getStars(ctx2)
139
+
140
+ expect(fetchMock).toHaveBeenCalledTimes(1)
141
+ expect(ctx1.body).toEqual({ stars: null, fetchedAt: null })
142
+ expect(ctx2.body).toEqual(ctx1.body)
143
+ })
144
+
145
+ it("returns last known value on refresh failure", async () => {
146
+ const fetchMock = getFetchMock()
147
+ fetchMock.mockResolvedValue({
148
+ ok: true,
149
+ status: 200,
150
+ json: async () => ({ stargazers_count: 111 }),
151
+ })
152
+
153
+ const { getStars } = require("../github")
154
+ const ctx1 = makeCtx()
155
+ await getStars(ctx1)
156
+
157
+ jest.setSystemTime(new Date(Date.now() + 6 * 60 * 60 * 1000 + 1))
158
+ fetchMock.mockRejectedValue(new Error("github down"))
159
+
160
+ const ctx2 = makeCtx()
161
+ await getStars(ctx2)
162
+
163
+ expect(fetchMock).toHaveBeenCalledTimes(2)
164
+ expect(ctx2.body).toEqual(ctx1.body)
165
+ })
166
+
167
+ it("retries after failure backoff expires", async () => {
168
+ const fetchMock = getFetchMock()
169
+ fetchMock.mockRejectedValue(new Error("timeout"))
170
+
171
+ const { getStars } = require("../github")
172
+ const ctx1 = makeCtx()
173
+ await getStars(ctx1)
174
+ const ctx2 = makeCtx()
175
+ await getStars(ctx2)
176
+
177
+ expect(fetchMock).toHaveBeenCalledTimes(1)
178
+
179
+ jest.setSystemTime(new Date(Date.now() + 5 * 60 * 1000 + 1))
180
+ fetchMock.mockResolvedValue({
181
+ ok: true,
182
+ status: 200,
183
+ json: async () => ({ stargazers_count: 222 }),
184
+ })
185
+
186
+ const ctx3 = makeCtx()
187
+ await getStars(ctx3)
188
+
189
+ expect(fetchMock).toHaveBeenCalledTimes(2)
190
+ assertHasBody(ctx3)
191
+ expect(ctx3.body.stars).toEqual(222)
192
+ })
193
+ })
@@ -268,10 +268,12 @@ export const adminUser = async (
268
268
  })
269
269
  }
270
270
 
271
- export const countByApp = async (ctx: UserCtx<void, CountUserResponse>) => {
272
- const appId = ctx.params.appId
271
+ export const countByWorkspace = async (
272
+ ctx: UserCtx<void, CountUserResponse>
273
+ ) => {
274
+ const workspaceId = ctx.params.workspaceId
273
275
  try {
274
- ctx.body = await userSdk.db.countUsersByApp(appId)
276
+ ctx.body = await userSdk.db.countUsersByWorkspace(workspaceId)
275
277
  } catch (err: any) {
276
278
  ctx.throw(err.status || 400, err)
277
279
  }
@@ -589,11 +591,12 @@ export const inviteMultiple = async (
589
591
  export const removeMultipleInvites = async (
590
592
  ctx: Ctx<DeleteInviteUsersRequest, DeleteInviteUsersResponse>
591
593
  ) => {
594
+ const tenantId = context.getTenantId()
592
595
  const inviteCodesToRemove = ctx.request.body.map(
593
596
  (invite: DeleteInviteUserRequest) => invite.code
594
597
  )
595
598
  for (const code of inviteCodesToRemove) {
596
- await cache.invite.deleteCode(code)
599
+ await cache.invite.deleteCode(code, tenantId)
597
600
  }
598
601
  ctx.body = {
599
602
  message: "User invites successfully removed.",
@@ -602,9 +605,13 @@ export const removeMultipleInvites = async (
602
605
 
603
606
  export const checkInvite = async (ctx: UserCtx<void, CheckInviteResponse>) => {
604
607
  const { code } = ctx.params
608
+ const tenantId =
609
+ typeof ctx.request.query.tenantId === "string"
610
+ ? ctx.request.query.tenantId
611
+ : undefined
605
612
  let invite
606
613
  try {
607
- invite = await cache.invite.getCode(code)
614
+ invite = await cache.invite.getCode(code, tenantId)
608
615
  } catch (e) {
609
616
  console.warn("Error getting invite from code", e)
610
617
  ctx.throw(400, "There was a problem with the invite")
@@ -637,7 +644,8 @@ export const addWorkspaceIdToInvite = async (
637
644
  const prodWorkspaceId = db.getProdWorkspaceID(workspaceId)
638
645
 
639
646
  try {
640
- const invite = await cache.invite.getCode(code)
647
+ const tenantId = context.getTenantId()
648
+ const invite = await cache.invite.getCode(code, tenantId)
641
649
  invite.info.apps ??= {}
642
650
  invite.info.apps[prodWorkspaceId] = role
643
651
 
@@ -660,7 +668,8 @@ export const removeWorkspaceIdFromInvite = async (
660
668
  const prodWorkspaceId = db.getProdWorkspaceID(workspaceId)
661
669
 
662
670
  try {
663
- const invite = await cache.invite.getCode(code)
671
+ const tenantId = context.getTenantId()
672
+ const invite = await cache.invite.getCode(code, tenantId)
664
673
  invite.info.apps ??= {}
665
674
  delete invite.info.apps[prodWorkspaceId]
666
675
 
@@ -674,7 +683,13 @@ export const removeWorkspaceIdFromInvite = async (
674
683
  export const inviteAccept = async (
675
684
  ctx: Ctx<AcceptUserInviteRequest, AcceptUserInviteResponse>
676
685
  ) => {
677
- const { inviteCode, password, firstName, lastName } = ctx.request.body
686
+ const { inviteCode, password, firstName, lastName, tenantId } =
687
+ ctx.request.body
688
+ const queryTenantId =
689
+ typeof ctx.request.query.tenantId === "string"
690
+ ? ctx.request.query.tenantId
691
+ : undefined
692
+ const resolvedTenantId = tenantId || queryTenantId
678
693
  try {
679
694
  await locks.doWithLock(
680
695
  {
@@ -685,7 +700,10 @@ export const inviteAccept = async (
685
700
  },
686
701
  async () => {
687
702
  // info is an extension of the user object that was stored by global
688
- const { email, info } = await cache.invite.getCode(inviteCode)
703
+ const { email, info } = await cache.invite.getCode(
704
+ inviteCode,
705
+ resolvedTenantId
706
+ )
689
707
  const user = await tenancy.doInTenant(info.tenantId, async () => {
690
708
  let request: any = {
691
709
  firstName,
@@ -715,7 +733,7 @@ export const inviteAccept = async (
715
733
  return saved
716
734
  })
717
735
 
718
- await cache.invite.deleteCode(inviteCode)
736
+ await cache.invite.deleteCode(inviteCode, resolvedTenantId)
719
737
 
720
738
  // make sure onboarding flow is cleared
721
739
  ctx.cookies.set(BpmStatusKey.ONBOARDING, BpmStatusValue.COMPLETED, {
@@ -0,0 +1,4 @@
1
+ import * as controller from "../../controllers/global/github"
2
+ import { builderRoutes } from "../endpointGroups"
3
+
4
+ builderRoutes.get("/api/global/github/stars", controller.getStars)
@@ -51,6 +51,7 @@ function buildInviteAcceptValidation() {
51
51
  password: Joi.string().optional(),
52
52
  firstName: Joi.string().optional(),
53
53
  lastName: Joi.string().optional(),
54
+ tenantId: Joi.string().optional(),
54
55
  }).required().unknown(true))
55
56
  }
56
57
 
@@ -91,7 +92,7 @@ adminRoutes
91
92
 
92
93
  builderOrAdminRoutes
93
94
  .get("/api/global/users", controller.fetch)
94
- .get("/api/global/users/count/:appId", controller.countByApp)
95
+ .get("/api/global/users/count/:workspaceId", controller.countByWorkspace)
95
96
  .get("/api/global/users/invites", controller.getUserInvites)
96
97
  .get("/api/global/users/:id", controller.find)
97
98
  .post(
@@ -7,6 +7,7 @@ import "./global/auth"
7
7
  import "./global/configs"
8
8
  import "./global/email"
9
9
  import "./global/events"
10
+ import "./global/github"
10
11
  import "./global/license"
11
12
  import "./global/roles"
12
13
  import "./global/self"
@@ -46,6 +46,7 @@ export class UserAPI extends TestAPI {
46
46
  password: "newpassword1",
47
47
  inviteCode: code,
48
48
  firstName: "Ted",
49
+ tenantId: this.config.getTenantId(),
49
50
  })
50
51
  .expect("Content-Type", /json/)
51
52
  .expect(200)