@budibase/backend-core 2.30.2 → 2.30.4

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.
Files changed (66) hide show
  1. package/dist/index.js +371 -169
  2. package/dist/index.js.map +3 -3
  3. package/dist/index.js.meta.json +1 -1
  4. package/dist/package.json +5 -5
  5. package/dist/plugins.js +1 -0
  6. package/dist/plugins.js.map +2 -2
  7. package/dist/plugins.js.meta.json +1 -1
  8. package/dist/src/context/mainContext.d.ts +2 -0
  9. package/dist/src/context/mainContext.js +19 -0
  10. package/dist/src/context/mainContext.js.map +1 -1
  11. package/dist/src/context/types.d.ts +3 -0
  12. package/dist/src/environment.d.ts +4 -0
  13. package/dist/src/environment.js +27 -1
  14. package/dist/src/environment.js.map +1 -1
  15. package/dist/src/events/processors/posthog/PosthogProcessor.d.ts +1 -1
  16. package/dist/src/events/processors/posthog/PosthogProcessor.js +2 -2
  17. package/dist/src/events/processors/posthog/PosthogProcessor.js.map +1 -1
  18. package/dist/src/features/index.d.ts +30 -28
  19. package/dist/src/features/index.js +200 -79
  20. package/dist/src/features/index.js.map +1 -1
  21. package/dist/src/index.d.ts +3 -1
  22. package/dist/src/index.js +3 -1
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/redis/redis.d.ts +1 -0
  25. package/dist/src/redis/redis.js +4 -0
  26. package/dist/src/redis/redis.js.map +1 -1
  27. package/dist/src/security/auth.js +1 -1
  28. package/dist/src/security/auth.js.map +1 -1
  29. package/dist/src/sql/sqlTable.js +23 -8
  30. package/dist/src/sql/sqlTable.js.map +1 -1
  31. package/dist/tests/core/utilities/mocks/index.d.ts +0 -2
  32. package/dist/tests/core/utilities/mocks/index.js +1 -7
  33. package/dist/tests/core/utilities/mocks/index.js.map +1 -1
  34. package/dist/tests/core/utilities/structures/users.js +1 -1
  35. package/dist/tests/core/utilities/structures/users.js.map +1 -1
  36. package/dist/tests/jestSetup.js +7 -2
  37. package/dist/tests/jestSetup.js.map +1 -1
  38. package/package.json +5 -5
  39. package/src/context/mainContext.ts +19 -0
  40. package/src/context/tests/index.spec.ts +1 -1
  41. package/src/context/types.ts +3 -0
  42. package/src/environment.ts +29 -0
  43. package/src/events/processors/posthog/PosthogProcessor.ts +1 -1
  44. package/src/events/processors/posthog/tests/PosthogProcessor.spec.ts +16 -22
  45. package/src/features/index.ts +242 -80
  46. package/src/features/tests/features.spec.ts +209 -63
  47. package/src/index.ts +1 -1
  48. package/src/middleware/passport/sso/tests/oidc.spec.ts +4 -12
  49. package/src/middleware/passport/sso/tests/sso.spec.ts +10 -12
  50. package/src/plugin/tests/validation.spec.ts +168 -42
  51. package/src/redis/redis.ts +4 -0
  52. package/src/redis/tests/redis.spec.ts +6 -3
  53. package/src/security/auth.ts +1 -1
  54. package/src/security/tests/auth.spec.ts +2 -2
  55. package/src/sql/sqlTable.ts +21 -7
  56. package/tests/core/utilities/mocks/index.ts +0 -2
  57. package/tests/core/utilities/structures/users.ts +1 -1
  58. package/tests/jestSetup.ts +10 -3
  59. package/dist/tests/core/utilities/mocks/fetch.d.ts +0 -32
  60. package/dist/tests/core/utilities/mocks/fetch.js +0 -15
  61. package/dist/tests/core/utilities/mocks/fetch.js.map +0 -1
  62. package/dist/tests/core/utilities/mocks/posthog.d.ts +0 -0
  63. package/dist/tests/core/utilities/mocks/posthog.js +0 -9
  64. package/dist/tests/core/utilities/mocks/posthog.js.map +0 -1
  65. package/tests/core/utilities/mocks/fetch.ts +0 -17
  66. package/tests/core/utilities/mocks/posthog.ts +0 -7
@@ -1,108 +1,270 @@
1
1
  import env from "../environment"
2
2
  import * as context from "../context"
3
- import { cloneDeep } from "lodash"
3
+ import { PostHog, PostHogOptions } from "posthog-node"
4
+ import { IdentityType, UserCtx } from "@budibase/types"
5
+ import tracer from "dd-trace"
4
6
 
5
- class Flag<T> {
6
- static withDefault<T>(value: T) {
7
- return new Flag(value)
7
+ let posthog: PostHog | undefined
8
+ export function init(opts?: PostHogOptions) {
9
+ if (env.POSTHOG_TOKEN && env.POSTHOG_API_HOST) {
10
+ console.log("initializing posthog client...")
11
+ posthog = new PostHog(env.POSTHOG_TOKEN, {
12
+ host: env.POSTHOG_API_HOST,
13
+ personalApiKey: env.POSTHOG_PERSONAL_TOKEN,
14
+ ...opts,
15
+ })
16
+ } else {
17
+ console.log("posthog disabled")
8
18
  }
9
-
10
- private constructor(public defaultValue: T) {}
11
19
  }
12
20
 
13
- // This is the primary source of truth for feature flags. If you want to add a
14
- // new flag, add it here and use the `fetch` and `get` functions to access it.
15
- // All of the machinery in this file is to make sure that flags have their
16
- // default values set correctly and their types flow through the system.
17
- const FLAGS = {
18
- LICENSING: Flag.withDefault(false),
19
- GOOGLE_SHEETS: Flag.withDefault(false),
20
- USER_GROUPS: Flag.withDefault(false),
21
- ONBOARDING_TOUR: Flag.withDefault(false),
21
+ export function shutdown() {
22
+ posthog?.shutdown()
22
23
  }
23
24
 
24
- const DEFAULTS = Object.keys(FLAGS).reduce((acc, key) => {
25
- const typedKey = key as keyof typeof FLAGS
26
- // @ts-ignore
27
- acc[typedKey] = FLAGS[typedKey].defaultValue
28
- return acc
29
- }, {} as Flags)
25
+ export abstract class Flag<T> {
26
+ static boolean(defaultValue: boolean): Flag<boolean> {
27
+ return new BooleanFlag(defaultValue)
28
+ }
29
+
30
+ static string(defaultValue: string): Flag<string> {
31
+ return new StringFlag(defaultValue)
32
+ }
33
+
34
+ static number(defaultValue: number): Flag<number> {
35
+ return new NumberFlag(defaultValue)
36
+ }
37
+
38
+ protected constructor(public defaultValue: T) {}
39
+
40
+ abstract parse(value: any): T
41
+ }
30
42
 
31
43
  type UnwrapFlag<F> = F extends Flag<infer U> ? U : never
32
- export type Flags = {
33
- [K in keyof typeof FLAGS]: UnwrapFlag<(typeof FLAGS)[K]>
44
+
45
+ export type FlagValues<T> = {
46
+ [K in keyof T]: UnwrapFlag<T[K]>
34
47
  }
35
48
 
36
- // Exported for use in tests, should not be used outside of this file.
37
- export function defaultFlags(): Flags {
38
- return cloneDeep(DEFAULTS)
49
+ type KeysOfType<T, U> = {
50
+ [K in keyof T]: T[K] extends Flag<U> ? K : never
51
+ }[keyof T]
52
+
53
+ class BooleanFlag extends Flag<boolean> {
54
+ parse(value: any) {
55
+ if (typeof value === "string") {
56
+ return ["true", "t", "1"].includes(value.toLowerCase())
57
+ }
58
+
59
+ if (typeof value === "boolean") {
60
+ return value
61
+ }
62
+
63
+ throw new Error(`could not parse value "${value}" as boolean`)
64
+ }
39
65
  }
40
66
 
41
- function isFlagName(name: string): name is keyof Flags {
42
- return FLAGS[name as keyof typeof FLAGS] !== undefined
67
+ class StringFlag extends Flag<string> {
68
+ parse(value: any) {
69
+ if (typeof value === "string") {
70
+ return value
71
+ }
72
+ throw new Error(`could not parse value "${value}" as string`)
73
+ }
43
74
  }
44
75
 
45
- /**
46
- * Reads the TENANT_FEATURE_FLAGS environment variable and returns a Flags object
47
- * populated with the flags for the current tenant, filling in the default values
48
- * if the flag is not set.
49
- *
50
- * Check the tests for examples of how TENANT_FEATURE_FLAGS should be formatted.
51
- *
52
- * In future we plan to add more ways of setting feature flags, e.g. PostHog, and
53
- * they will be accessed through this function as well.
54
- */
55
- export async function fetch(): Promise<Flags> {
56
- const currentTenantId = context.getTenantId()
57
- const flags = defaultFlags()
58
-
59
- const split = (env.TENANT_FEATURE_FLAGS || "")
60
- .split(",")
61
- .map(x => x.split(":"))
62
- for (const [tenantId, ...features] of split) {
63
- if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) {
64
- continue
76
+ class NumberFlag extends Flag<number> {
77
+ parse(value: any) {
78
+ if (typeof value === "number") {
79
+ return value
65
80
  }
66
81
 
67
- for (let feature of features) {
68
- let value = true
69
- if (feature.startsWith("!")) {
70
- feature = feature.slice(1)
71
- value = false
82
+ if (typeof value === "string") {
83
+ const parsed = parseFloat(value)
84
+ if (!isNaN(parsed)) {
85
+ return parsed
72
86
  }
87
+ }
88
+
89
+ throw new Error(`could not parse value "${value}" as number`)
90
+ }
91
+ }
92
+
93
+ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
94
+ // This is used to safely cache flags sets in the current request context.
95
+ // Because multiple sets could theoretically exist, we don't want the cache of
96
+ // one to leak into another.
97
+ private readonly setId: string
98
+
99
+ constructor(private readonly flagSchema: T) {
100
+ this.setId = crypto.randomUUID()
101
+ }
102
+
103
+ defaults(): FlagValues<T> {
104
+ return Object.keys(this.flagSchema).reduce((acc, key) => {
105
+ const typedKey = key as keyof T
106
+ acc[typedKey] = this.flagSchema[key].defaultValue
107
+ return acc
108
+ }, {} as FlagValues<T>)
109
+ }
110
+
111
+ isFlagName(name: string | number | symbol): name is keyof T {
112
+ return this.flagSchema[name as keyof T] !== undefined
113
+ }
73
114
 
74
- if (!isFlagName(feature)) {
75
- throw new Error(`Feature: ${feature} is not an allowed option`)
115
+ async get<K extends keyof T>(
116
+ key: K,
117
+ ctx?: UserCtx
118
+ ): Promise<FlagValues<T>[K]> {
119
+ const flags = await this.fetch(ctx)
120
+ return flags[key]
121
+ }
122
+
123
+ async isEnabled<K extends KeysOfType<T, boolean>>(
124
+ key: K,
125
+ ctx?: UserCtx
126
+ ): Promise<boolean> {
127
+ const flags = await this.fetch(ctx)
128
+ return flags[key]
129
+ }
130
+
131
+ async fetch(ctx?: UserCtx): Promise<FlagValues<T>> {
132
+ return await tracer.trace("features.fetch", async span => {
133
+ const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
134
+ if (cachedFlags) {
135
+ span?.addTags({ fromCache: true })
136
+ return cachedFlags
76
137
  }
77
138
 
78
- if (typeof flags[feature] !== "boolean") {
79
- throw new Error(`Feature: ${feature} is not a boolean`)
139
+ const tags: Record<string, any> = {}
140
+ const flagValues = this.defaults()
141
+ const currentTenantId = context.getTenantId()
142
+ const specificallySetFalse = new Set<string>()
143
+
144
+ const split = (env.TENANT_FEATURE_FLAGS || "")
145
+ .split(",")
146
+ .map(x => x.split(":"))
147
+ for (const [tenantId, ...features] of split) {
148
+ if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) {
149
+ continue
150
+ }
151
+
152
+ tags[`readFromEnvironmentVars`] = true
153
+
154
+ for (let feature of features) {
155
+ let value = true
156
+ if (feature.startsWith("!")) {
157
+ feature = feature.slice(1)
158
+ value = false
159
+ specificallySetFalse.add(feature)
160
+ }
161
+
162
+ // ignore unknown flags
163
+ if (!this.isFlagName(feature)) {
164
+ continue
165
+ }
166
+
167
+ if (typeof flagValues[feature] !== "boolean") {
168
+ throw new Error(`Feature: ${feature} is not a boolean`)
169
+ }
170
+
171
+ // @ts-expect-error - TS does not like you writing into a generic type,
172
+ // but we know that it's okay in this case because it's just an object.
173
+ flagValues[feature as keyof FlagValues] = value
174
+ tags[`flags.${feature}.source`] = "environment"
175
+ }
80
176
  }
81
177
 
82
- // @ts-ignore
83
- flags[feature] = value
84
- }
85
- }
178
+ const license = ctx?.user?.license
179
+ if (license) {
180
+ tags[`readFromLicense`] = true
86
181
 
87
- return flags
88
- }
182
+ for (const feature of license.features) {
183
+ if (!this.isFlagName(feature)) {
184
+ continue
185
+ }
89
186
 
90
- // Gets a single feature flag value. This is a convenience function for
91
- // `fetch().then(flags => flags[name])`.
92
- export async function get<K extends keyof Flags>(name: K): Promise<Flags[K]> {
93
- const flags = await fetch()
94
- return flags[name]
95
- }
187
+ if (
188
+ flagValues[feature] === true ||
189
+ specificallySetFalse.has(feature)
190
+ ) {
191
+ // If the flag is already set to through environment variables, we
192
+ // don't want to override it back to false here.
193
+ continue
194
+ }
195
+
196
+ // @ts-expect-error - TS does not like you writing into a generic type,
197
+ // but we know that it's okay in this case because it's just an object.
198
+ flagValues[feature] = true
199
+ tags[`flags.${feature}.source`] = "license"
200
+ }
201
+ }
96
202
 
97
- type BooleanFlags = {
98
- [K in keyof typeof FLAGS]: (typeof FLAGS)[K] extends Flag<boolean> ? K : never
99
- }[keyof typeof FLAGS]
100
-
101
- // Convenience function for boolean flag values. This makes callsites more
102
- // readable for boolean flags.
103
- export async function isEnabled<K extends BooleanFlags>(
104
- name: K
105
- ): Promise<boolean> {
106
- const flags = await fetch()
107
- return flags[name]
203
+ const identity = context.getIdentity()
204
+ tags[`identity.type`] = identity?.type
205
+ tags[`identity.tenantId`] = identity?.tenantId
206
+ tags[`identity._id`] = identity?._id
207
+
208
+ if (posthog && identity?.type === IdentityType.USER) {
209
+ tags[`readFromPostHog`] = true
210
+
211
+ const personProperties: Record<string, string> = {}
212
+ if (identity.tenantId) {
213
+ personProperties.tenantId = identity.tenantId
214
+ }
215
+
216
+ const posthogFlags = await posthog.getAllFlagsAndPayloads(
217
+ identity._id,
218
+ {
219
+ personProperties,
220
+ }
221
+ )
222
+
223
+ for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
224
+ if (!this.isFlagName(name)) {
225
+ // We don't want an unexpected PostHog flag to break the app, so we
226
+ // just log it and continue.
227
+ console.warn(`Unexpected posthog flag "${name}": ${value}`)
228
+ continue
229
+ }
230
+
231
+ if (flagValues[name] === true || specificallySetFalse.has(name)) {
232
+ // If the flag is already set to through environment variables, we
233
+ // don't want to override it back to false here.
234
+ continue
235
+ }
236
+
237
+ const payload = posthogFlags.featureFlagPayloads?.[name]
238
+ const flag = this.flagSchema[name]
239
+ try {
240
+ // @ts-expect-error - TS does not like you writing into a generic
241
+ // type, but we know that it's okay in this case because it's just
242
+ // an object.
243
+ flagValues[name] = flag.parse(payload || value)
244
+ tags[`flags.${name}.source`] = "posthog"
245
+ } catch (err) {
246
+ // We don't want an invalid PostHog flag to break the app, so we just
247
+ // log it and continue.
248
+ console.warn(`Error parsing posthog flag "${name}": ${value}`, err)
249
+ }
250
+ }
251
+ }
252
+
253
+ context.setFeatureFlags(this.setId, flagValues)
254
+ for (const [key, value] of Object.entries(flagValues)) {
255
+ tags[`flags.${key}.value`] = value
256
+ }
257
+ span?.addTags(tags)
258
+
259
+ return flagValues
260
+ })
261
+ }
108
262
  }
263
+
264
+ // This is the primary source of truth for feature flags. If you want to add a
265
+ // new flag, add it here and use the `fetch` and `get` functions to access it.
266
+ // All of the machinery in this file is to make sure that flags have their
267
+ // default values set correctly and their types flow through the system.
268
+ export const flags = new FlagSet({
269
+ DEFAULT_VALUES: Flag.boolean(false),
270
+ })
@@ -1,86 +1,232 @@
1
- import { defaultFlags, fetch, get, Flags } from "../"
2
- import { context } from "../.."
3
- import env from "../../environment"
4
-
5
- async function withFlags<T>(flags: string, f: () => T): Promise<T> {
6
- const oldFlags = env.TENANT_FEATURE_FLAGS
7
- env._set("TENANT_FEATURE_FLAGS", flags)
8
- try {
9
- return await f()
10
- } finally {
11
- env._set("TENANT_FEATURE_FLAGS", oldFlags)
12
- }
1
+ import { IdentityContext, IdentityType, UserCtx } from "@budibase/types"
2
+ import { Flag, FlagSet, FlagValues, init, shutdown } from "../"
3
+ import * as context from "../../context"
4
+ import environment, { withEnv } from "../../environment"
5
+ import nodeFetch from "node-fetch"
6
+ import nock from "nock"
7
+
8
+ const schema = {
9
+ TEST_BOOLEAN: Flag.boolean(false),
10
+ TEST_STRING: Flag.string("default value"),
11
+ TEST_NUMBER: Flag.number(0),
12
+ }
13
+ const flags = new FlagSet(schema)
14
+
15
+ interface TestCase {
16
+ it: string
17
+ identity?: Partial<IdentityContext>
18
+ environmentFlags?: string
19
+ posthogFlags?: PostHogFlags
20
+ licenseFlags?: Array<string>
21
+ expected?: Partial<FlagValues<typeof schema>>
22
+ errorMessage?: string | RegExp
23
+ }
24
+
25
+ interface PostHogFlags {
26
+ featureFlags?: Record<string, boolean>
27
+ featureFlagPayloads?: Record<string, string>
28
+ }
29
+
30
+ function mockPosthogFlags(flags: PostHogFlags) {
31
+ nock("https://us.i.posthog.com")
32
+ .post("/decide/?v=3", body => {
33
+ return body.token === "test" && body.distinct_id === "us_1234"
34
+ })
35
+ .reply(200, flags)
36
+ .persist()
13
37
  }
14
38
 
15
39
  describe("feature flags", () => {
16
- interface TestCase {
17
- tenant: string
18
- flags: string
19
- expected: Partial<Flags>
20
- }
40
+ beforeEach(() => {
41
+ nock.cleanAll()
42
+ })
21
43
 
22
44
  it.each<TestCase>([
23
45
  {
24
- tenant: "tenant1",
25
- flags: "tenant1:ONBOARDING_TOUR",
26
- expected: { ONBOARDING_TOUR: true },
46
+ it: "should should find a simple boolean flag in the environment",
47
+ environmentFlags: "default:TEST_BOOLEAN",
48
+ expected: { TEST_BOOLEAN: true },
49
+ },
50
+ {
51
+ it: "should should find a simple netgative boolean flag in the environment",
52
+ environmentFlags: "default:!TEST_BOOLEAN",
53
+ expected: { TEST_BOOLEAN: false },
54
+ },
55
+ {
56
+ it: "should should match stars in the environment",
57
+ environmentFlags: "*:TEST_BOOLEAN",
58
+ expected: { TEST_BOOLEAN: true },
27
59
  },
28
60
  {
29
- tenant: "tenant1",
30
- flags: "tenant1:!ONBOARDING_TOUR",
31
- expected: { ONBOARDING_TOUR: false },
61
+ it: "should not match a different tenant's flags",
62
+ environmentFlags: "otherTenant:TEST_BOOLEAN",
63
+ expected: { TEST_BOOLEAN: false },
32
64
  },
33
65
  {
34
- tenant: "tenant1",
35
- flags: "*:ONBOARDING_TOUR",
36
- expected: { ONBOARDING_TOUR: true },
66
+ it: "should return the defaults when no flags are set",
67
+ expected: flags.defaults(),
37
68
  },
38
69
  {
39
- tenant: "tenant1",
40
- flags: "tenant2:ONBOARDING_TOUR",
41
- expected: { ONBOARDING_TOUR: false },
70
+ it: "should ignore unknown feature flags",
71
+ environmentFlags: "default:TEST_BOOLEAN,default:FOO",
72
+ expected: { TEST_BOOLEAN: true },
42
73
  },
43
74
  {
44
- tenant: "tenant1",
45
- flags: "",
46
- expected: defaultFlags(),
75
+ it: "should be able to read boolean flags from PostHog",
76
+ posthogFlags: {
77
+ featureFlags: { TEST_BOOLEAN: true },
78
+ },
79
+ expected: { TEST_BOOLEAN: true },
80
+ },
81
+ {
82
+ it: "should be able to read string flags from PostHog",
83
+ posthogFlags: {
84
+ featureFlags: { TEST_STRING: true },
85
+ featureFlagPayloads: { TEST_STRING: "test" },
86
+ },
87
+ expected: { TEST_STRING: "test" },
88
+ },
89
+ {
90
+ it: "should be able to read numeric flags from PostHog",
91
+ posthogFlags: {
92
+ featureFlags: { TEST_NUMBER: true },
93
+ featureFlagPayloads: { TEST_NUMBER: "123" },
94
+ },
95
+ expected: { TEST_NUMBER: 123 },
96
+ },
97
+ {
98
+ it: "should not be able to override a negative environment flag from PostHog",
99
+ environmentFlags: "default:!TEST_BOOLEAN",
100
+ posthogFlags: {
101
+ featureFlags: { TEST_BOOLEAN: true },
102
+ },
103
+ expected: { TEST_BOOLEAN: false },
104
+ },
105
+ {
106
+ it: "should not be able to override a positive environment flag from PostHog",
107
+ environmentFlags: "default:TEST_BOOLEAN",
108
+ posthogFlags: {
109
+ featureFlags: {
110
+ TEST_BOOLEAN: false,
111
+ },
112
+ },
113
+ expected: { TEST_BOOLEAN: true },
114
+ },
115
+ {
116
+ it: "should be able to set boolean flags through the license",
117
+ licenseFlags: ["TEST_BOOLEAN"],
118
+ expected: { TEST_BOOLEAN: true },
119
+ },
120
+ {
121
+ it: "should not be able to override a negative environment flag from license",
122
+ environmentFlags: "default:!TEST_BOOLEAN",
123
+ licenseFlags: ["TEST_BOOLEAN"],
124
+ expected: { TEST_BOOLEAN: false },
125
+ },
126
+ {
127
+ it: "should not error on unrecognised PostHog flag",
128
+ posthogFlags: {
129
+ featureFlags: { UNDEFINED: true },
130
+ },
131
+ expected: flags.defaults(),
132
+ },
133
+ {
134
+ it: "should not error on unrecognised license flag",
135
+ licenseFlags: ["UNDEFINED"],
136
+ expected: flags.defaults(),
47
137
  },
48
138
  ])(
49
- 'should find flags $expected for $tenant with string "$flags"',
50
- ({ tenant, flags, expected }) =>
51
- context.doInTenant(tenant, () =>
52
- withFlags(flags, async () => {
53
- const flags = await fetch()
54
- expect(flags).toMatchObject(expected)
55
-
56
- for (const [key, expectedValue] of Object.entries(expected)) {
57
- const value = await get(key as keyof Flags)
58
- expect(value).toBe(expectedValue)
59
- }
139
+ "$it",
140
+ async ({
141
+ identity,
142
+ environmentFlags,
143
+ posthogFlags,
144
+ licenseFlags,
145
+ expected,
146
+ errorMessage,
147
+ }) => {
148
+ const env: Partial<typeof environment> = {
149
+ TENANT_FEATURE_FLAGS: environmentFlags,
150
+ }
151
+
152
+ if (posthogFlags) {
153
+ mockPosthogFlags(posthogFlags)
154
+ env.POSTHOG_TOKEN = "test"
155
+ env.POSTHOG_API_HOST = "https://us.i.posthog.com"
156
+ env.POSTHOG_PERSONAL_TOKEN = "test"
157
+ }
158
+
159
+ const ctx = { user: { license: { features: licenseFlags || [] } } }
160
+
161
+ await withEnv(env, async () => {
162
+ // We need to pass in node-fetch here otherwise nock won't get used
163
+ // because posthog-node uses axios under the hood.
164
+ init({
165
+ fetch: (url, opts) => {
166
+ return nodeFetch(url, opts)
167
+ },
60
168
  })
61
- )
62
- )
63
169
 
64
- interface FailedTestCase {
65
- tenant: string
66
- flags: string
67
- expected: string | RegExp
68
- }
170
+ const fullIdentity: IdentityContext = {
171
+ _id: "us_1234",
172
+ tenantId: "default",
173
+ type: IdentityType.USER,
174
+ email: "test@example.com",
175
+ firstName: "Test",
176
+ lastName: "User",
177
+ ...identity,
178
+ }
69
179
 
70
- it.each<FailedTestCase>([
71
- {
72
- tenant: "tenant1",
73
- flags: "tenant1:ONBOARDING_TOUR,tenant1:FOO",
74
- expected: "Feature: FOO is not an allowed option",
75
- },
76
- ])(
77
- "should fail with message \"$expected\" for $tenant with string '$flags'",
78
- async ({ tenant, flags, expected }) => {
79
- context.doInTenant(tenant, () =>
80
- withFlags(flags, async () => {
81
- await expect(fetch()).rejects.toThrow(expected)
180
+ await context.doInIdentityContext(fullIdentity, async () => {
181
+ if (errorMessage) {
182
+ await expect(flags.fetch(ctx as UserCtx)).rejects.toThrow(
183
+ errorMessage
184
+ )
185
+ } else if (expected) {
186
+ const values = await flags.fetch(ctx as UserCtx)
187
+ expect(values).toMatchObject(expected)
188
+
189
+ for (const [key, expectedValue] of Object.entries(expected)) {
190
+ const value = await flags.get(
191
+ key as keyof typeof schema,
192
+ ctx as UserCtx
193
+ )
194
+ expect(value).toBe(expectedValue)
195
+ }
196
+ } else {
197
+ throw new Error("No expected value")
198
+ }
82
199
  })
83
- )
200
+
201
+ shutdown()
202
+ })
84
203
  }
85
204
  )
205
+
206
+ it("should not error if PostHog is down", async () => {
207
+ const identity: IdentityContext = {
208
+ _id: "us_1234",
209
+ tenantId: "default",
210
+ type: IdentityType.USER,
211
+ email: "test@example.com",
212
+ firstName: "Test",
213
+ lastName: "User",
214
+ }
215
+
216
+ nock("https://us.i.posthog.com")
217
+ .post("/decide/?v=3", body => {
218
+ return body.token === "test" && body.distinct_id === "us_1234"
219
+ })
220
+ .reply(503)
221
+ .persist()
222
+
223
+ await withEnv(
224
+ { POSTHOG_TOKEN: "test", POSTHOG_API_HOST: "https://us.i.posthog.com" },
225
+ async () => {
226
+ await context.doInIdentityContext(identity, async () => {
227
+ await flags.fetch()
228
+ })
229
+ }
230
+ )
231
+ })
86
232
  })
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ export * as locks from "./redis/redlockImpl"
27
27
  export * as utils from "./utils"
28
28
  export * as errors from "./errors"
29
29
  export * as timers from "./timers"
30
- export { default as env } from "./environment"
30
+ export { default as env, withEnv, setEnv } from "./environment"
31
31
  export * as blacklist from "./blacklist"
32
32
  export * as docUpdates from "./docUpdates"
33
33
  export * from "./utils/Duration"