@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.
- package/Models/DatabaseModels/Host.ts +12 -4
- package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
- package/Models/DatabaseModels/KubernetesResource.ts +8 -7
- package/Models/DatabaseModels/PodmanResource.ts +6 -6
- package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
- package/Models/DatabaseModels/StatusPage.ts +95 -0
- package/Server/API/TeamMemberAPI.ts +85 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
- package/Server/Middleware/ProjectAuthorization.ts +66 -4
- package/Server/Services/BillingService.ts +600 -51
- package/Server/Services/CephClusterService.ts +47 -5
- package/Server/Services/CephResourceService.ts +43 -4
- package/Server/Services/CloudResourceService.ts +47 -5
- package/Server/Services/DatabaseService.ts +87 -6
- package/Server/Services/DockerHostService.ts +47 -5
- package/Server/Services/DockerResourceService.ts +62 -8
- package/Server/Services/DockerSwarmClusterService.ts +48 -5
- package/Server/Services/DockerSwarmResourceService.ts +53 -4
- package/Server/Services/HostService.ts +48 -5
- package/Server/Services/IoTDeviceService.ts +1 -17
- package/Server/Services/IoTFleetService.ts +47 -5
- package/Server/Services/KubernetesClusterService.ts +48 -5
- package/Server/Services/KubernetesContainerService.ts +50 -9
- package/Server/Services/KubernetesResourceService.ts +53 -7
- package/Server/Services/LabelService.ts +106 -1
- package/Server/Services/MonitorService.ts +57 -0
- package/Server/Services/PodmanHostService.ts +47 -5
- package/Server/Services/PodmanResourceService.ts +62 -8
- package/Server/Services/ProjectService.ts +165 -35
- package/Server/Services/ProxmoxClusterService.ts +47 -5
- package/Server/Services/ProxmoxResourceService.ts +43 -4
- package/Server/Services/RumApplicationService.ts +48 -5
- package/Server/Services/ServerlessFunctionService.ts +48 -5
- package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
- package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
- package/Server/Services/StatusPageService.ts +95 -36
- package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
- package/Server/Services/StatusPageSubscriberService.ts +3 -0
- package/Server/Types/Database/JSONColumnQuery.ts +621 -0
- package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
- package/Server/Types/Database/QueryHelper.ts +17 -32
- package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
- package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
- package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
- package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
- package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
- package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
- package/Tests/Server/Services/BillingService.test.ts +78 -3
- package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
- package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
- package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
- package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
- package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
- package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
- package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
- package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
- package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
- package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
- package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
- package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
- package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
- package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
- package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
- package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
- package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
- package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
- package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
- package/Tests/Types/Events/Recurring.test.ts +157 -0
- package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
- package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
- package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
- package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
- package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
- package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
- package/Types/CustomField/CustomFieldDefinition.ts +24 -0
- package/Types/Date.ts +112 -0
- package/Types/Events/Recurring.ts +99 -1
- package/Types/StatusPage/StatusPageReport.ts +11 -0
- package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
- package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
- package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
- package/UI/Utils/TeamMembersByUser.ts +302 -0
- package/Utils/StatusPage/ReportPeriod.ts +236 -0
- package/Utils/Telemetry/HostIpAddresses.ts +98 -0
- package/build/dist/Models/DatabaseModels/Host.js +12 -4
- package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
- package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
- package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
- package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
- package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
- package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
- package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
- package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
- package/build/dist/Server/API/TeamMemberAPI.js +57 -2
- package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
- package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
- package/build/dist/Server/Services/BillingService.js +466 -36
- package/build/dist/Server/Services/BillingService.js.map +1 -1
- package/build/dist/Server/Services/CephClusterService.js +42 -5
- package/build/dist/Server/Services/CephClusterService.js.map +1 -1
- package/build/dist/Server/Services/CephResourceService.js +28 -3
- package/build/dist/Server/Services/CephResourceService.js.map +1 -1
- package/build/dist/Server/Services/CloudResourceService.js +42 -5
- package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
- package/build/dist/Server/Services/DatabaseService.js +69 -2
- package/build/dist/Server/Services/DatabaseService.js.map +1 -1
- package/build/dist/Server/Services/DockerHostService.js +42 -5
- package/build/dist/Server/Services/DockerHostService.js.map +1 -1
- package/build/dist/Server/Services/DockerResourceService.js +34 -4
- package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
- package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
- package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
- package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
- package/build/dist/Server/Services/HostService.js +43 -5
- package/build/dist/Server/Services/HostService.js.map +1 -1
- package/build/dist/Server/Services/IoTDeviceService.js +1 -8
- package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
- package/build/dist/Server/Services/IoTFleetService.js +42 -5
- package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
- package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
- package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
- package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
- package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
- package/build/dist/Server/Services/LabelService.js +92 -0
- package/build/dist/Server/Services/LabelService.js.map +1 -1
- package/build/dist/Server/Services/MonitorService.js +47 -0
- package/build/dist/Server/Services/MonitorService.js.map +1 -1
- package/build/dist/Server/Services/PodmanHostService.js +42 -5
- package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
- package/build/dist/Server/Services/PodmanResourceService.js +34 -4
- package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +135 -21
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
- package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
- package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
- package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
- package/build/dist/Server/Services/RumApplicationService.js +43 -5
- package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
- package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
- package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
- package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageService.js +69 -46
- package/build/dist/Server/Services/StatusPageService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
- package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
- package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
- package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
- package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
- package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
- package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
- package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
- package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
- package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
- package/build/dist/Types/Date.js +68 -0
- package/build/dist/Types/Date.js.map +1 -1
- package/build/dist/Types/Events/Recurring.js +63 -0
- package/build/dist/Types/Events/Recurring.js.map +1 -1
- package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
- package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
- package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
- package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
- package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
- package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
- package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
- package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
- package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
- package/jest.config.json +2 -0
- package/package.json +1 -1
- 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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
+
});
|