@oneuptime/common 12.0.1 → 12.0.2

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 (205) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  5. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  6. package/Models/DatabaseModels/StatusPage.ts +95 -0
  7. package/Server/API/TeamMemberAPI.ts +85 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  13. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  14. package/Server/Services/BillingService.ts +600 -51
  15. package/Server/Services/CephClusterService.ts +47 -5
  16. package/Server/Services/CephResourceService.ts +43 -4
  17. package/Server/Services/CloudResourceService.ts +47 -5
  18. package/Server/Services/DatabaseService.ts +87 -6
  19. package/Server/Services/DockerHostService.ts +47 -5
  20. package/Server/Services/DockerResourceService.ts +62 -8
  21. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  22. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  23. package/Server/Services/HostService.ts +48 -5
  24. package/Server/Services/IoTDeviceService.ts +1 -17
  25. package/Server/Services/IoTFleetService.ts +47 -5
  26. package/Server/Services/KubernetesClusterService.ts +48 -5
  27. package/Server/Services/KubernetesContainerService.ts +50 -9
  28. package/Server/Services/KubernetesResourceService.ts +53 -7
  29. package/Server/Services/LabelService.ts +106 -1
  30. package/Server/Services/MonitorService.ts +57 -0
  31. package/Server/Services/PodmanHostService.ts +47 -5
  32. package/Server/Services/PodmanResourceService.ts +62 -8
  33. package/Server/Services/ProjectService.ts +165 -35
  34. package/Server/Services/ProxmoxClusterService.ts +47 -5
  35. package/Server/Services/ProxmoxResourceService.ts +43 -4
  36. package/Server/Services/RumApplicationService.ts +48 -5
  37. package/Server/Services/ServerlessFunctionService.ts +48 -5
  38. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  39. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  40. package/Server/Services/StatusPageService.ts +95 -36
  41. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  42. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  43. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  44. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  45. package/Server/Types/Database/QueryHelper.ts +17 -32
  46. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  47. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  48. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  49. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  50. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  51. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  52. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  53. package/Tests/Server/Services/BillingService.test.ts +78 -3
  54. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  55. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  56. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  57. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  58. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  59. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  60. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  61. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  62. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  63. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  64. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  65. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  66. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  67. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  68. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  69. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  70. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  71. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  72. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  73. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  74. package/Tests/Types/Events/Recurring.test.ts +157 -0
  75. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  76. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  77. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  78. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  79. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  80. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  81. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  82. package/Types/Date.ts +112 -0
  83. package/Types/Events/Recurring.ts +99 -1
  84. package/Types/StatusPage/StatusPageReport.ts +11 -0
  85. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  86. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  87. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  88. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  89. package/UI/Utils/TeamMembersByUser.ts +302 -0
  90. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  91. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  92. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  93. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  95. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  97. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  99. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  101. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  103. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  104. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  105. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  106. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  107. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  108. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  109. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  110. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  113. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  114. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  116. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  117. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  118. package/build/dist/Server/Services/BillingService.js +466 -36
  119. package/build/dist/Server/Services/BillingService.js.map +1 -1
  120. package/build/dist/Server/Services/CephClusterService.js +42 -5
  121. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  122. package/build/dist/Server/Services/CephResourceService.js +28 -3
  123. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  124. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  125. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  126. package/build/dist/Server/Services/DatabaseService.js +69 -2
  127. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  128. package/build/dist/Server/Services/DockerHostService.js +42 -5
  129. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  130. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  131. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  132. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  133. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  134. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  135. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  136. package/build/dist/Server/Services/HostService.js +43 -5
  137. package/build/dist/Server/Services/HostService.js.map +1 -1
  138. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  139. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  140. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  141. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  142. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  143. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  144. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  145. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  146. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  147. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  148. package/build/dist/Server/Services/LabelService.js +92 -0
  149. package/build/dist/Server/Services/LabelService.js.map +1 -1
  150. package/build/dist/Server/Services/MonitorService.js +47 -0
  151. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  152. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  153. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  154. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  155. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  156. package/build/dist/Server/Services/ProjectService.js +135 -21
  157. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  158. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  159. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  160. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  161. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  162. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  163. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  164. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  165. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  166. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  167. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  168. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  169. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  170. package/build/dist/Server/Services/StatusPageService.js +69 -46
  171. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  172. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  173. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  174. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  175. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  176. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  177. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  178. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  179. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  180. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  181. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  182. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  183. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  184. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  185. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  186. package/build/dist/Types/Date.js +68 -0
  187. package/build/dist/Types/Date.js.map +1 -1
  188. package/build/dist/Types/Events/Recurring.js +63 -0
  189. package/build/dist/Types/Events/Recurring.js.map +1 -1
  190. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  191. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  192. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  193. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  194. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  195. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  196. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  197. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  198. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  199. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  200. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  201. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  202. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  203. package/jest.config.json +2 -0
  204. package/package.json +1 -1
  205. package/tsconfig.json +12 -1
@@ -61,32 +61,92 @@ const getStripeCustomer: GetStripeCustomerFunction = (
61
61
  type GetStripeSubscriptionFunction = (options?: {
62
62
  id?: string | undefined;
63
63
  status?: Stripe.Subscription.Status | undefined;
64
+
65
+ /*
66
+ * The trial Stripe itself reports on the subscription, as a unix timestamp -
67
+ * what a real subscription carries and what changePlan reads to make sure a
68
+ * plan change cannot cut a running trial short. Omitted means no trial.
69
+ */
70
+ trialEnd?: number | null | undefined;
71
+ customer?: string | undefined;
72
+
73
+ /*
74
+ * The id of the subscription's single item - the handle changePlan swaps the
75
+ * new plan's price onto. An empty array stands for a subscription with no
76
+ * items at all, which has no such handle.
77
+ */
78
+ itemId?: string | undefined;
79
+ items?: Array<{ id: string }> | undefined;
64
80
  }) => Stripe.Subscription;
65
81
 
66
82
  const getStripeSubscription: GetStripeSubscriptionFunction = (options?: {
67
83
  id?: string | undefined;
68
84
  status?: Stripe.Subscription.Status | undefined;
85
+ trialEnd?: number | null | undefined;
86
+ customer?: string | undefined;
87
+ itemId?: string | undefined;
88
+ items?: Array<{ id: string }> | undefined;
69
89
  }): Stripe.Subscription => {
90
+ const items: Array<{ id: string }> = options?.items || [
91
+ { id: options?.itemId || Faker.generateRandomObjectID().toString() },
92
+ ];
93
+
70
94
  return {
71
95
  id: options?.id || Faker.generateRandomObjectID().toString(),
72
96
  items: {
73
- data: [
74
- {
75
- id: Faker.generateRandomObjectID().toString(),
76
- // @ts-expect-error - Simplified mock price object for testing without all required Stripe Price properties
97
+ data: items.map((item: { id: string }) => {
98
+ // Simplified mock item: only the fields the service actually reads.
99
+ return {
100
+ id: item.id,
77
101
  price: {
78
102
  id: new BillingService().getMeteredPlanPriceId(
79
103
  ProductType.ActiveMonitoring,
80
104
  ),
81
105
  },
82
- },
83
- ],
106
+ } as Stripe.SubscriptionItem;
107
+ }),
84
108
  },
85
109
  status: options?.status || "active",
86
- customer: getStripeCustomer(),
87
- };
110
+ trial_end: options?.trialEnd ?? null,
111
+ customer: options?.customer || getStripeCustomer(),
112
+ } as Stripe.Subscription;
88
113
  };
89
114
 
115
+ /*
116
+ * A plan with an exact trial period, for the cases where the trial length is
117
+ * the thing under test. getSubscriptionPlanData randomises it between 1 and
118
+ * 100, so it can never produce the 0-trial-day plans (Basic, Scale) that the
119
+ * mid-trial plan change has to keep trialing.
120
+ */
121
+ type GetSubscriptionPlanWithTrialPeriodFunction = (
122
+ trialPeriodInDays: number,
123
+ options?: {
124
+ name?: string | undefined;
125
+ monthlyPlanId?: string | undefined;
126
+ yearlyPlanId?: string | undefined;
127
+ },
128
+ ) => SubscriptionPlan;
129
+
130
+ const getSubscriptionPlanWithTrialPeriod: GetSubscriptionPlanWithTrialPeriodFunction =
131
+ (
132
+ trialPeriodInDays: number,
133
+ options?: {
134
+ name?: string | undefined;
135
+ monthlyPlanId?: string | undefined;
136
+ yearlyPlanId?: string | undefined;
137
+ },
138
+ ): SubscriptionPlan => {
139
+ return new SubscriptionPlan(
140
+ options?.monthlyPlanId || "price_monthly_test_plan",
141
+ options?.yearlyPlanId || "price_yearly_test_plan",
142
+ options?.name || "Scale",
143
+ 99, // monthlySubscriptionAmountInUSD
144
+ 84, // yearlySubscriptionAmountInUSD
145
+ 3, // order
146
+ trialPeriodInDays,
147
+ );
148
+ };
149
+
90
150
  type GetSubscriptionPlanDataFunction = () => SubscriptionPlan;
91
151
 
92
152
  const getSubscriptionPlanData: GetSubscriptionPlanDataFunction =
@@ -200,6 +260,7 @@ export {
200
260
  getStripeCustomer,
201
261
  getStripeSubscription,
202
262
  getSubscriptionPlanData,
263
+ getSubscriptionPlanWithTrialPeriod,
203
264
  getCustomerData,
204
265
  getSubscriptionData,
205
266
  getMeteredSubscription,
@@ -0,0 +1,593 @@
1
+ import buildJSONColumnQuery, {
2
+ JSONColumnQuery,
3
+ MAX_JSON_QUERY_KEYS,
4
+ MAX_JSON_QUERY_KEY_LENGTH,
5
+ MAX_JSON_QUERY_VALUES_PER_KEY,
6
+ escapeLikePattern,
7
+ } from "../../../../Server/Types/Database/JSONColumnQuery";
8
+ import EndsWith from "../../../../Types/BaseDatabase/EndsWith";
9
+ import EqualTo from "../../../../Types/BaseDatabase/EqualTo";
10
+ import EqualToOrNull from "../../../../Types/BaseDatabase/EqualToOrNull";
11
+ import GreaterThan from "../../../../Types/BaseDatabase/GreaterThan";
12
+ import GreaterThanOrEqual from "../../../../Types/BaseDatabase/GreaterThanOrEqual";
13
+ import InBetween from "../../../../Types/BaseDatabase/InBetween";
14
+ import Includes from "../../../../Types/BaseDatabase/Includes";
15
+ import IncludesAll from "../../../../Types/BaseDatabase/IncludesAll";
16
+ import IncludesNone from "../../../../Types/BaseDatabase/IncludesNone";
17
+ import IsNull from "../../../../Types/BaseDatabase/IsNull";
18
+ import LessThan from "../../../../Types/BaseDatabase/LessThan";
19
+ import LessThanOrEqual from "../../../../Types/BaseDatabase/LessThanOrEqual";
20
+ import NotContains from "../../../../Types/BaseDatabase/NotContains";
21
+ import NotEqual from "../../../../Types/BaseDatabase/NotEqual";
22
+ import NotNull from "../../../../Types/BaseDatabase/NotNull";
23
+ import Search from "../../../../Types/BaseDatabase/Search";
24
+ import StartsWith from "../../../../Types/BaseDatabase/StartsWith";
25
+ import BadDataException from "../../../../Types/Exception/BadDataException";
26
+ import ObjectID from "../../../../Types/ObjectID";
27
+ import { JSONObject } from "../../../../Types/JSON";
28
+ import { describe, expect, test } from "@jest/globals";
29
+
30
+ /*
31
+ * This module compiles the WHERE clause for "filter incidents by the value of
32
+ * a custom field", so two whole classes of bug live here and nowhere else.
33
+ *
34
+ * The first is injection. A custom field's name is text a project member
35
+ * types, and it used to be spliced straight into the SQL string — every test
36
+ * below that inspects `parameters` rather than the SQL is guarding the fix.
37
+ *
38
+ * The second is silent wrong answers. A multi-select stores its value as a
39
+ * JSON array, a Number custom field stores its value as a *string*, and a row
40
+ * that never had the field at all has no key. Each of those, read naively,
41
+ * produces a filter that runs, returns rows, and is wrong — the worst possible
42
+ * failure for something people use to report on their incidents.
43
+ *
44
+ * The tests use a deterministic parameter-name generator so the emitted SQL
45
+ * can be asserted verbatim. Production uses random names purely so two
46
+ * operators in one query cannot collide.
47
+ */
48
+
49
+ const COLUMN: string = '"Incident"."customFields"';
50
+
51
+ type SequentialNamesFunction = () => () => string;
52
+
53
+ const sequentialNames: SequentialNamesFunction = (): (() => string) => {
54
+ let index: number = 0;
55
+
56
+ return (): string => {
57
+ index++;
58
+ return `p${index}`;
59
+ };
60
+ };
61
+
62
+ type BuildFunction = (value: JSONObject) => JSONColumnQuery;
63
+
64
+ const build: BuildFunction = (value: JSONObject): JSONColumnQuery => {
65
+ return buildJSONColumnQuery({
66
+ value: value,
67
+ generateParameterName: sequentialNames(),
68
+ });
69
+ };
70
+
71
+ type SqlOfFunction = (value: JSONObject) => string;
72
+
73
+ const sqlOf: SqlOfFunction = (value: JSONObject): string => {
74
+ return build(value).toSql(COLUMN);
75
+ };
76
+
77
+ describe("buildJSONColumnQuery — the shape of a key lookup", () => {
78
+ test("reads the key through a bound parameter, never through the SQL text", () => {
79
+ const query: JSONColumnQuery = build({ Team: "Payments" });
80
+ const sql: string = query.toSql(COLUMN);
81
+
82
+ // The key appears as a parameter value...
83
+ expect(query.parameters["p1"]).toBe("Team");
84
+ // ...and never as a literal in the statement.
85
+ expect(sql).not.toContain("'Team'");
86
+ expect(sql).toContain("->> CAST(:p1 AS TEXT)");
87
+ expect(sql).toContain("-> CAST(:p1 AS TEXT)");
88
+ });
89
+
90
+ test("casts the key so the overloaded -> / ->> operators resolve", () => {
91
+ /*
92
+ * `jsonb -> text` and `jsonb -> integer` are both defined. An untyped bind
93
+ * parameter leaves Postgres unable to pick one and the query fails with
94
+ * "operator is not unique" — which only shows up against a real database,
95
+ * so it is pinned here.
96
+ */
97
+ expect(sqlOf({ Team: "Payments" })).toContain("CAST(:p1 AS TEXT)");
98
+ });
99
+
100
+ test("quotes nothing itself — the caller supplies the column reference", () => {
101
+ const query: JSONColumnQuery = build({ Team: "Payments" });
102
+
103
+ expect(query.toSql('"Alert"."customFields"')).toContain(
104
+ '"Alert"."customFields"',
105
+ );
106
+ });
107
+ });
108
+
109
+ describe("buildJSONColumnQuery — SQL injection through the key", () => {
110
+ test("a key that closes the quote and appends a predicate is inert", () => {
111
+ const key: string = "a' = 'a' OR 1=1 OR 'x";
112
+ const query: JSONColumnQuery = build({ [key]: "v" });
113
+ const sql: string = query.toSql(COLUMN);
114
+
115
+ expect(sql).not.toContain("OR 1=1");
116
+ expect(sql).not.toContain(key);
117
+ expect(Object.values(query.parameters)).toContain(key);
118
+ });
119
+
120
+ test("a key carrying a statement terminator is inert", () => {
121
+ const key: string = '"; DROP TABLE "Incident"; --';
122
+ const query: JSONColumnQuery = build({ [key]: "v" });
123
+ const sql: string = query.toSql(COLUMN);
124
+
125
+ expect(sql).not.toContain("DROP TABLE");
126
+ expect(Object.values(query.parameters)).toContain(key);
127
+ });
128
+
129
+ test("a value that closes the quote is inert", () => {
130
+ const value: string = "x' OR 'a'='a";
131
+ const query: JSONColumnQuery = build({ Team: value });
132
+
133
+ expect(query.toSql(COLUMN)).not.toContain("OR 'a'='a");
134
+ expect(Object.values(query.parameters)).toContain(value);
135
+ });
136
+
137
+ test("the emitted statement contains no single-quoted user data at all", () => {
138
+ const sql: string = sqlOf({
139
+ "'quoted'": new Includes(["'also quoted'"]),
140
+ "%wild%": new Search("'sneaky'"),
141
+ });
142
+
143
+ /*
144
+ * Every literal the builder writes is one it authored: the jsonb type
145
+ * names, the empty string, and the numeric guard's regex. Anything else
146
+ * between quotes is user data that reached the statement text.
147
+ */
148
+ const authored: Set<string> = new Set<string>([
149
+ "''",
150
+ "'array'",
151
+ "'null'",
152
+ `'^\\s*-?[0-9]+(\\.[0-9]+)?\\s*$'`,
153
+ ]);
154
+
155
+ for (const literal of sql.match(/'[^']*'/g) || []) {
156
+ expect(authored.has(literal)).toBe(true);
157
+ }
158
+ });
159
+
160
+ test("the numeric guard's regex is the only literal a comparison writes", () => {
161
+ const sql: string = sqlOf({ "'k'": new GreaterThan(1) });
162
+
163
+ for (const literal of sql.match(/'[^']*'/g) || []) {
164
+ expect(literal).toBe(`'^\\s*-?[0-9]+(\\.[0-9]+)?\\s*$'`);
165
+ }
166
+ });
167
+ });
168
+
169
+ describe("buildJSONColumnQuery — statelessness", () => {
170
+ test("renders the same SQL when asked twice", () => {
171
+ /*
172
+ * The previous implementation rewrote its captured template inside the
173
+ * Raw callback, so the second render (findBy and countBy each resolve the
174
+ * operator) saw an already-substituted string. Pinning both calls keeps
175
+ * that from coming back.
176
+ */
177
+ const query: JSONColumnQuery = build({ Team: "Payments" });
178
+
179
+ expect(query.toSql(COLUMN)).toBe(query.toSql(COLUMN));
180
+ });
181
+
182
+ test("renders correctly for a second, different column reference", () => {
183
+ const query: JSONColumnQuery = build({ Team: "Payments" });
184
+
185
+ query.toSql('"Incident"."customFields"');
186
+ const second: string = query.toSql('"Alert"."customFields"');
187
+
188
+ expect(second).toContain('"Alert"."customFields"');
189
+ expect(second).not.toContain('"Incident"');
190
+ });
191
+
192
+ test("does not mint new parameters on render", () => {
193
+ const query: JSONColumnQuery = build({ Team: "Payments" });
194
+ const before: number = Object.keys(query.parameters).length;
195
+
196
+ query.toSql(COLUMN);
197
+ query.toSql(COLUMN);
198
+
199
+ expect(Object.keys(query.parameters).length).toBe(before);
200
+ });
201
+ });
202
+
203
+ describe("buildJSONColumnQuery — equality", () => {
204
+ test("matches a scalar value", () => {
205
+ const query: JSONColumnQuery = build({ Team: "Payments" });
206
+
207
+ expect(query.toSql(COLUMN)).toContain(
208
+ `${COLUMN} ->> CAST(:p1 AS TEXT) = CAST(:p2 AS TEXT)`,
209
+ );
210
+ expect(query.parameters["p2"]).toBe("Payments");
211
+ });
212
+
213
+ test("also matches a value stored inside an array", () => {
214
+ /*
215
+ * A multi-select stores ["Payments","Billing"]. Without the containment
216
+ * arm, `->>` returns the literal text '["Payments", "Billing"]' and the
217
+ * filter quietly returns nothing — the highest-value assertion in the file.
218
+ */
219
+ const query: JSONColumnQuery = build({ Team: "Payments" });
220
+ const sql: string = query.toSql(COLUMN);
221
+
222
+ expect(sql).toContain("jsonb_typeof(");
223
+ expect(sql).toContain("= 'array'");
224
+ expect(sql).toContain("@> CAST(:p3 AS JSONB)");
225
+ expect(query.parameters["p3"]).toBe('["Payments"]');
226
+ });
227
+
228
+ test("keeps a number a number in the containment literal", () => {
229
+ const query: JSONColumnQuery = build({ Count: 42 });
230
+
231
+ expect(query.parameters["p2"]).toBe("42");
232
+ expect(query.parameters["p3"]).toBe("[42]");
233
+ });
234
+
235
+ test("keeps a boolean a boolean in the containment literal", () => {
236
+ const query: JSONColumnQuery = build({ Regulated: true });
237
+
238
+ expect(query.parameters["p2"]).toBe("true");
239
+ expect(query.parameters["p3"]).toBe("[true]");
240
+ });
241
+
242
+ test("EqualTo compiles the same predicate as a bare value", () => {
243
+ expect(sqlOf({ Team: new EqualTo("Payments") })).toBe(
244
+ sqlOf({ Team: "Payments" }),
245
+ );
246
+ });
247
+
248
+ test("EqualToOrNull also matches rows with no value", () => {
249
+ const sql: string = sqlOf({ Team: new EqualToOrNull("Payments") });
250
+
251
+ expect(sql).toContain("IS NULL");
252
+ expect(sql).toContain(" OR ");
253
+ });
254
+ });
255
+
256
+ describe("buildJSONColumnQuery — negation includes rows without the key", () => {
257
+ test("NotEqual uses IS NOT TRUE rather than NOT", () => {
258
+ /*
259
+ * A row without the key compares to NULL. `NOT NULL` is NULL, which drops
260
+ * exactly the rows "Team is not Payments" is asked about; `IS NOT TRUE`
261
+ * keeps them.
262
+ */
263
+ const sql: string = sqlOf({ Team: new NotEqual("Payments") });
264
+
265
+ expect(sql).toContain("IS NOT TRUE");
266
+ expect(sql).not.toMatch(/NOT\s+\(/);
267
+ });
268
+
269
+ test("IncludesNone negates the whole any-of predicate", () => {
270
+ const sql: string = sqlOf({ Team: new IncludesNone(["a", "b"]) });
271
+
272
+ expect(sql).toContain(" OR ");
273
+ expect(sql).toContain("IS NOT TRUE");
274
+ });
275
+
276
+ test("NotContains negates the ILIKE", () => {
277
+ const sql: string = sqlOf({ Team: new NotContains("pay") });
278
+
279
+ expect(sql).toContain("ILIKE");
280
+ expect(sql).toContain("IS NOT TRUE");
281
+ });
282
+ });
283
+
284
+ describe("buildJSONColumnQuery — multi-value operators", () => {
285
+ test("Includes ORs one equality per value", () => {
286
+ const query: JSONColumnQuery = build({
287
+ Team: new Includes(["Payments", "Billing"]),
288
+ });
289
+ const sql: string = query.toSql(COLUMN);
290
+
291
+ expect(sql).toContain(" OR ");
292
+ expect(query.parameters["p2"]).toBe("Payments");
293
+ expect(query.parameters["p4"]).toBe("Billing");
294
+ });
295
+
296
+ test("IncludesAll ANDs one equality per value", () => {
297
+ const sql: string = sqlOf({
298
+ Team: new IncludesAll(["Payments", "Billing"]),
299
+ });
300
+
301
+ expect(sql).toContain(" AND ");
302
+ expect(sql).not.toContain(" OR CAST");
303
+ });
304
+
305
+ test("a bare array reads as is-any-of", () => {
306
+ expect(sqlOf({ Team: ["a", "b"] })).toBe(
307
+ sqlOf({ Team: new Includes(["a", "b"]) }),
308
+ );
309
+ });
310
+
311
+ test("ObjectID values stringify", () => {
312
+ const id: ObjectID = ObjectID.generate();
313
+ const query: JSONColumnQuery = build({ Owner: new Includes([id]) });
314
+
315
+ expect(query.parameters["p2"]).toBe(id.toString());
316
+ });
317
+
318
+ test("an empty selection constrains nothing rather than matching nothing", () => {
319
+ /*
320
+ * A cleared multi-select must not hide the table. `(1 = 1)` is the honest
321
+ * compilation of "the user selected nothing".
322
+ */
323
+ expect(sqlOf({ Team: new Includes([]) })).toBe("(1 = 1)");
324
+ expect(sqlOf({ Team: new IncludesNone([]) })).toBe("(1 = 1)");
325
+ expect(sqlOf({ Team: new IncludesAll([]) })).toBe("(1 = 1)");
326
+ expect(sqlOf({ Team: [] })).toBe("(1 = 1)");
327
+ });
328
+
329
+ test("an empty selection alongside a real one leaves the real one", () => {
330
+ const sql: string = sqlOf({
331
+ Team: new Includes([]),
332
+ Region: "us-east-1",
333
+ });
334
+
335
+ expect(sql).not.toBe("(1 = 1)");
336
+ expect(sql).toContain("CAST(:p3 AS TEXT)");
337
+ });
338
+ });
339
+
340
+ describe("buildJSONColumnQuery — text search", () => {
341
+ test("Search wraps the needle on both sides", () => {
342
+ const query: JSONColumnQuery = build({ Notes: new Search("outage") });
343
+
344
+ expect(query.toSql(COLUMN)).toContain("ILIKE :p2");
345
+ expect(query.parameters["p2"]).toBe("%outage%");
346
+ });
347
+
348
+ test("StartsWith and EndsWith anchor the pattern", () => {
349
+ expect(build({ Notes: new StartsWith("out") }).parameters["p2"]).toBe(
350
+ "out%",
351
+ );
352
+ expect(build({ Notes: new EndsWith("age") }).parameters["p2"]).toBe("%age");
353
+ });
354
+
355
+ test("wildcards inside the needle are escaped", () => {
356
+ /*
357
+ * "contains 100%" must not become "contains 100 followed by anything".
358
+ */
359
+ expect(build({ Notes: new Search("100%") }).parameters["p2"]).toBe(
360
+ "%100\\%%",
361
+ );
362
+ expect(build({ Notes: new Search("a_b") }).parameters["p2"]).toBe(
363
+ "%a\\_b%",
364
+ );
365
+ expect(build({ Notes: new Search("c:\\tmp") }).parameters["p2"]).toBe(
366
+ "%c:\\\\tmp%",
367
+ );
368
+ });
369
+
370
+ test("escapeLikePattern doubles the escape character first", () => {
371
+ expect(escapeLikePattern("\\%")).toBe("\\\\\\%");
372
+ });
373
+ });
374
+
375
+ describe("buildJSONColumnQuery — numeric comparison", () => {
376
+ test("guards the cast with a CASE, not an AND", () => {
377
+ /*
378
+ * A Number custom field is stored as text, and the column holds every
379
+ * field's value, so a comparison meets arbitrary strings. Postgres does
380
+ * not promise to evaluate an AND guard before the cast, and one
381
+ * "invalid input syntax for type numeric" aborts the whole request.
382
+ */
383
+ const sql: string = sqlOf({ Count: new GreaterThan(5) });
384
+
385
+ expect(sql).toContain("CASE WHEN");
386
+ expect(sql).toContain("AS NUMERIC");
387
+ expect(sql).toContain("ELSE NULL END");
388
+ });
389
+
390
+ test("emits the right operator for each comparison", () => {
391
+ expect(sqlOf({ Count: new GreaterThan(5) })).toContain(") > CAST(");
392
+ expect(sqlOf({ Count: new GreaterThanOrEqual(5) })).toContain(") >= CAST(");
393
+ expect(sqlOf({ Count: new LessThan(5) })).toContain(") < CAST(");
394
+ expect(sqlOf({ Count: new LessThanOrEqual(5) })).toContain(") <= CAST(");
395
+ });
396
+
397
+ test("binds the comparand as a number", () => {
398
+ expect(build({ Count: new GreaterThan(5) }).parameters["p2"]).toBe(5);
399
+ });
400
+
401
+ test("InBetween over numbers compares numerically", () => {
402
+ const sql: string = sqlOf({ Count: new InBetween(1, 10) });
403
+
404
+ expect(sql).toContain("AS NUMERIC");
405
+ expect(sql).toContain(" >= ");
406
+ expect(sql).toContain(" <= ");
407
+ });
408
+
409
+ test("InBetween over non-numbers compares as text", () => {
410
+ /*
411
+ * ISO-8601 dates sort lexicographically in calendar order, so a text
412
+ * BETWEEN is exact for them — and does not risk a cast error on a row
413
+ * holding something else.
414
+ */
415
+ const sql: string = sqlOf({
416
+ Window: new InBetween("2026-01-01", "2026-02-01"),
417
+ });
418
+
419
+ expect(sql).not.toContain("AS NUMERIC");
420
+ expect(sql).toContain("AS TEXT");
421
+ });
422
+ });
423
+
424
+ describe("buildJSONColumnQuery — emptiness", () => {
425
+ test("IsNull covers absent, JSON null, empty string and empty array", () => {
426
+ const sql: string = sqlOf({ Team: new IsNull() });
427
+
428
+ expect(sql).toContain("IS NULL");
429
+ expect(sql).toContain("jsonb_typeof");
430
+ expect(sql).toContain("'null'");
431
+ expect(sql).toContain("= ''");
432
+ expect(sql).toContain("jsonb_array_length");
433
+ });
434
+
435
+ test("NotNull is the negation of that", () => {
436
+ const sql: string = sqlOf({ Team: new NotNull() });
437
+
438
+ expect(sql).toContain("jsonb_array_length");
439
+ expect(sql).toContain("IS NOT TRUE");
440
+ });
441
+
442
+ test("an explicit null value reads as is-empty", () => {
443
+ expect(sqlOf({ Team: null } as unknown as JSONObject)).toBe(
444
+ sqlOf({ Team: new IsNull() }),
445
+ );
446
+ });
447
+ });
448
+
449
+ describe("buildJSONColumnQuery — backwards compatibility", () => {
450
+ test("an empty object still means the whole column is empty", () => {
451
+ /*
452
+ * Every JSON filter that has ever been cleared sends `{}`. Retargeting it
453
+ * would silently change what those saved filters mean.
454
+ */
455
+ expect(sqlOf({})).toBe(`(${COLUMN} IS NULL OR ${COLUMN} = '{}')`);
456
+ });
457
+
458
+ test("several plain keys AND together, as they always have", () => {
459
+ const query: JSONColumnQuery = build({ a: "1", b: "2" });
460
+ const sql: string = query.toSql(COLUMN);
461
+
462
+ expect(sql).toContain(" AND ");
463
+ expect(query.parameters["p1"]).toBe("a");
464
+ expect(query.parameters["p2"]).toBe("1");
465
+ expect(query.parameters["p4"]).toBe("b");
466
+ expect(query.parameters["p5"]).toBe("2");
467
+ });
468
+
469
+ test("a JSON string argument is parsed", () => {
470
+ const query: JSONColumnQuery = buildJSONColumnQuery({
471
+ value: '{"Team":"Payments"}',
472
+ generateParameterName: sequentialNames(),
473
+ });
474
+
475
+ expect(query.parameters["p1"]).toBe("Team");
476
+ expect(query.parameters["p2"]).toBe("Payments");
477
+ });
478
+
479
+ test("an unparseable string degrades to the empty-column predicate", () => {
480
+ const query: JSONColumnQuery = buildJSONColumnQuery({
481
+ value: "not json at all",
482
+ generateParameterName: sequentialNames(),
483
+ });
484
+
485
+ expect(query.toSql(COLUMN)).toBe(`(${COLUMN} IS NULL OR ${COLUMN} = '{}')`);
486
+ });
487
+
488
+ test("a nested object reads as containment", () => {
489
+ const query: JSONColumnQuery = build({
490
+ Address: { city: "Berlin" } as unknown as string,
491
+ });
492
+
493
+ expect(query.toSql(COLUMN)).toContain("@> CAST(:p2 AS JSONB)");
494
+ expect(query.parameters["p2"]).toBe('{"city":"Berlin"}');
495
+ });
496
+
497
+ test("an empty key is skipped", () => {
498
+ expect(sqlOf({ "": "x" })).toBe("(1 = 1)");
499
+ });
500
+ });
501
+
502
+ describe("buildJSONColumnQuery — limits", () => {
503
+ test("refuses more keys than the cap", () => {
504
+ const value: JSONObject = {};
505
+
506
+ for (let i: number = 0; i <= MAX_JSON_QUERY_KEYS; i++) {
507
+ value[`key${i}`] = "v";
508
+ }
509
+
510
+ expect(() => {
511
+ return build(value);
512
+ }).toThrow(BadDataException);
513
+ });
514
+
515
+ test("refuses more values on one key than the cap", () => {
516
+ const values: Array<string> = [];
517
+
518
+ for (let i: number = 0; i <= MAX_JSON_QUERY_VALUES_PER_KEY; i++) {
519
+ values.push(`v${i}`);
520
+ }
521
+
522
+ expect(() => {
523
+ return build({ Team: new Includes(values) });
524
+ }).toThrow(BadDataException);
525
+ });
526
+
527
+ test("refuses a key longer than the cap", () => {
528
+ expect(() => {
529
+ return build({ ["k".repeat(MAX_JSON_QUERY_KEY_LENGTH + 1)]: "v" });
530
+ }).toThrow(BadDataException);
531
+ });
532
+
533
+ test("accepts a payload exactly at the caps", () => {
534
+ const values: Array<string> = [];
535
+
536
+ for (let i: number = 0; i < MAX_JSON_QUERY_VALUES_PER_KEY; i++) {
537
+ values.push(`v${i}`);
538
+ }
539
+
540
+ expect(() => {
541
+ return build({
542
+ ["k".repeat(MAX_JSON_QUERY_KEY_LENGTH)]: new Includes(values),
543
+ });
544
+ }).not.toThrow();
545
+ });
546
+ });
547
+
548
+ describe("buildJSONColumnQuery — parameter hygiene", () => {
549
+ test("mints a distinct parameter for every bound value", () => {
550
+ const query: JSONColumnQuery = build({
551
+ Team: new Includes(["a", "b"]),
552
+ Region: "us-east-1",
553
+ });
554
+
555
+ const names: Array<string> = Object.keys(query.parameters);
556
+
557
+ expect(new Set(names).size).toBe(names.length);
558
+ });
559
+
560
+ test("production names do not collide across keys", () => {
561
+ const query: JSONColumnQuery = buildJSONColumnQuery({
562
+ value: { a: "1", b: "2", c: "3" },
563
+ });
564
+
565
+ const names: Array<string> = Object.keys(query.parameters);
566
+
567
+ expect(new Set(names).size).toBe(names.length);
568
+ expect(names.length).toBe(9);
569
+ });
570
+
571
+ test("every placeholder in the SQL has a parameter behind it", () => {
572
+ const query: JSONColumnQuery = buildJSONColumnQuery({
573
+ value: {
574
+ Team: new Includes(["a", "b"]),
575
+ Count: new GreaterThan(3),
576
+ Notes: new Search("x"),
577
+ Cleared: new IsNull(),
578
+ },
579
+ });
580
+
581
+ const placeholders: Array<string> = (
582
+ query.toSql(COLUMN).match(/:[A-Za-z]+/g) || []
583
+ ).map((placeholder: string) => {
584
+ return placeholder.slice(1);
585
+ });
586
+
587
+ expect(placeholders.length).toBeGreaterThan(0);
588
+
589
+ for (const placeholder of placeholders) {
590
+ expect(query.parameters).toHaveProperty(placeholder);
591
+ }
592
+ });
593
+ });