@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 +2 -2
- package/src/api/controllers/global/github.ts +77 -0
- package/src/api/controllers/global/tests/github.spec.ts +193 -0
- package/src/api/controllers/global/users.ts +28 -10
- package/src/api/routes/global/github.ts +4 -0
- package/src/api/routes/global/users.ts +2 -1
- package/src/api/routes/index.ts +1 -0
- package/src/tests/api/users.ts +1 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@budibase/worker",
|
|
3
3
|
"email": "hi@budibase.com",
|
|
4
|
-
"version": "3.
|
|
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": "
|
|
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
|
|
272
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 } =
|
|
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(
|
|
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, {
|
|
@@ -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/:
|
|
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(
|
package/src/api/routes/index.ts
CHANGED