@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
@@ -7,6 +7,7 @@ import Typeof from "../../../Types/Typeof";
7
7
  import { FindOperator, Raw } from "typeorm";
8
8
  import { FindWhereProperty } from "../../../Types/BaseDatabase/Query";
9
9
  import CaptureSpan from "../../Utils/Telemetry/CaptureSpan";
10
+ import buildJSONColumnQuery, { JSONColumnQuery } from "./JSONColumnQuery";
10
11
 
11
12
  export default class QueryHelper {
12
13
  @CaptureSpan()
@@ -668,40 +669,25 @@ export default class QueryHelper {
668
669
  ) as FindWhereProperty<any>;
669
670
  }
670
671
 
672
+ /**
673
+ * Filter on the keys of a jsonb column — `customFields` above all, whose
674
+ * keys are the names a project gave its custom fields.
675
+ *
676
+ * The per-key predicates (equality, "is any of", contains, numeric
677
+ * comparison, is-empty, and their negations) live in JSONColumnQuery. Two
678
+ * things this used to get wrong and no longer does: the key was spliced
679
+ * straight into the SQL string, and every value was compared with `=`, so an
680
+ * operator such as `Includes` reached Postgres stringified.
681
+ */
671
682
  @CaptureSpan()
672
683
  public static queryJson(value: JSONObject): FindWhereProperty<any> {
673
- // seed random text
674
- const values: JSONObject = {};
675
-
676
- let queryText: string = "";
677
-
678
- if (typeof value === Typeof.String) {
679
- value = JSON.parse(value.toString());
680
- }
681
-
682
684
  if (value instanceof FindOperator) {
683
685
  return value;
684
686
  }
685
687
 
686
- const hasValue: boolean = value && Object.keys(value).length > 0;
687
-
688
- for (const key in value) {
689
- const temp: string = Text.generateRandomText(10);
690
-
691
- values[temp] = value[key];
692
-
693
- if (!queryText) {
694
- queryText = `(COLUMN_NAME_ALIAS->>'${key}' = :${temp as string}`;
695
- } else {
696
- queryText += ` AND COLUMN_NAME_ALIAS->>'${key}' = :${temp as string}`;
697
- }
698
- }
699
-
700
- if (hasValue) {
701
- queryText += ")";
702
- } else {
703
- queryText = "(COLUMN_NAME_ALIAS IS NULL OR COLUMN_NAME_ALIAS = '{}')";
704
- }
688
+ const jsonQuery: JSONColumnQuery = buildJSONColumnQuery({
689
+ value: value as JSONObject,
690
+ });
705
691
 
706
692
  return Raw((alias: string) => {
707
693
  /*
@@ -709,16 +695,15 @@ export default class QueryHelper {
709
695
  * we need to convert this to "tableName"."columnName"
710
696
  */
711
697
 
712
- alias = alias
698
+ const columnReference: string = alias
713
699
  .split(".")
714
700
  .map((item: string) => {
715
701
  return `"${item}"`;
716
702
  })
717
703
  .join(".");
718
704
 
719
- queryText = Text.replaceAll(queryText, "COLUMN_NAME_ALIAS", `${alias}`);
720
- return queryText;
721
- }, values);
705
+ return jsonQuery.toSql(columnReference);
706
+ }, jsonQuery.parameters);
722
707
  }
723
708
 
724
709
  @CaptureSpan()
@@ -0,0 +1,84 @@
1
+ import ColumnLength from "../../../Types/Database/ColumnLength";
2
+
3
+ /*
4
+ * ------------------------------------------------------------------
5
+ * TruncateColumnValue
6
+ *
7
+ * Per-value clamps for bounded varchar columns written by the bulk
8
+ * inventory upsert paths (Kubernetes / Docker / Docker Swarm / Podman /
9
+ * Proxmox / Ceph / IoT).
10
+ *
11
+ * Those services hand-build `INSERT ... ON CONFLICT` statements and
12
+ * flush them in chunks of 500 rows through
13
+ * `getRepository().manager.query(...)`, which bypasses the length
14
+ * validation DatabaseService applies to ordinary CRUD writes. A single
15
+ * value past its column limit raises 22001 ("value too long for type
16
+ * character varying(N)") and aborts the WHOLE multi-row INSERT, so one
17
+ * malformed resource silently drops up to 499 healthy inventory rows
18
+ * with it. Clamping per value keeps the blast radius at one field.
19
+ *
20
+ * Truncation is the last line of defense, not the primary fix: columns
21
+ * that legitimately carry long values (image references, Kubernetes
22
+ * DNS-subdomain names) should be widened in the model + a migration so
23
+ * these clamps effectively never fire.
24
+ * ------------------------------------------------------------------
25
+ */
26
+
27
+ export function truncateToLength(value: string, maxLength: number): string;
28
+ export function truncateToLength(
29
+ value: string | null | undefined,
30
+ maxLength: number,
31
+ ): string | null;
32
+ export function truncateToLength(
33
+ value: string | null | undefined,
34
+ maxLength: number,
35
+ ): string | null {
36
+ if (value === null || value === undefined) {
37
+ return null;
38
+ }
39
+
40
+ if (value.length <= maxLength) {
41
+ return value;
42
+ }
43
+
44
+ const clipped: string = value.substring(0, maxLength);
45
+
46
+ /*
47
+ * JS string length counts UTF-16 code units, so a cut at maxLength can
48
+ * land in the middle of a surrogate pair (any astral-plane character:
49
+ * emoji, rare CJK). The orphaned high surrogate is not valid UTF-8 —
50
+ * Node encodes it as U+FFFD on the way to the driver — so drop it.
51
+ * A well-formed string never ends a character on a high surrogate,
52
+ * which makes this check unambiguous. Postgres counts characters, not
53
+ * code units, so clipping by code units is always conservative: the
54
+ * result can only be shorter than the column bound, never longer.
55
+ */
56
+ const lastCodeUnit: number = clipped.charCodeAt(clipped.length - 1);
57
+ if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) {
58
+ return clipped.substring(0, clipped.length - 1);
59
+ }
60
+
61
+ return clipped;
62
+ }
63
+
64
+ /** Clamp to ColumnLength.ShortText — varchar(100). */
65
+ export function truncateShortText(value: string): string;
66
+ export function truncateShortText(
67
+ value: string | null | undefined,
68
+ ): string | null;
69
+ export function truncateShortText(
70
+ value: string | null | undefined,
71
+ ): string | null {
72
+ return truncateToLength(value, ColumnLength.ShortText);
73
+ }
74
+
75
+ /** Clamp to ColumnLength.LongText — varchar(500). */
76
+ export function truncateLongText(value: string): string;
77
+ export function truncateLongText(
78
+ value: string | null | undefined,
79
+ ): string | null;
80
+ export function truncateLongText(
81
+ value: string | null | undefined,
82
+ ): string | null {
83
+ return truncateToLength(value, ColumnLength.LongText);
84
+ }
@@ -0,0 +1,448 @@
1
+ import "@testing-library/jest-dom";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import React, { ReactElement } from "react";
4
+ import {
5
+ afterEach,
6
+ beforeEach,
7
+ describe,
8
+ expect,
9
+ jest,
10
+ test,
11
+ } from "@jest/globals";
12
+ import { MemoryRouter } from "react-router-dom";
13
+
14
+ /*
15
+ * The customer-facing bug this whole change exists for: Settings > Users
16
+ * listed TeamMember, a (user, team) join row, so one person on three teams
17
+ * filled three rows with the same name and avatar and read as three different
18
+ * people.
19
+ *
20
+ * The unit suites cover the grouping util and the API class. Neither of them
21
+ * touches the page, and the fix on the page is a single prop -
22
+ * modelAPI={ProjectUsersModelAPI} - whose loss brings every duplicate straight
23
+ * back with no test failing. That prop, the column that now has to render a
24
+ * list of teams rather than one, and the row's derived status are what this
25
+ * file pins.
26
+ */
27
+
28
+ jest.mock("../../../UI/Utils/Permission", () => {
29
+ return {
30
+ __esModule: true,
31
+ default: {
32
+ getAllPermissions: () => {
33
+ return [];
34
+ },
35
+ getProjectPermissions: () => {
36
+ return [];
37
+ },
38
+ getGlobalPermissions: () => {
39
+ return [];
40
+ },
41
+ },
42
+ };
43
+ });
44
+
45
+ jest.mock("../../../UI/Utils/User", () => {
46
+ return {
47
+ __esModule: true,
48
+ default: {
49
+ isMasterAdmin: () => {
50
+ return true;
51
+ },
52
+ getUserId: () => {
53
+ return null;
54
+ },
55
+ },
56
+ };
57
+ });
58
+
59
+ jest.mock("../../../UI/Utils/Translation", () => {
60
+ return {
61
+ __esModule: true,
62
+ default: () => {
63
+ return {
64
+ translateString: (value: string | undefined) => {
65
+ return value;
66
+ },
67
+ translateValue: (value: unknown) => {
68
+ return value;
69
+ },
70
+ };
71
+ },
72
+ };
73
+ });
74
+
75
+ /*
76
+ * The page is rendered through a stand-in ModelTable that records the props it
77
+ * was given. Rendering the real one would drag in the facet bar, the URL
78
+ * state and the pager, none of which this file is about - what matters is
79
+ * which API the page hands the table, and what its columns do with a grouped
80
+ * row.
81
+ */
82
+ type CapturedTableProps = {
83
+ modelAPI?: unknown;
84
+ columns: Array<{
85
+ title: string;
86
+ disableSort?: boolean | undefined;
87
+ getElement?: ((item: TeamMember) => ReactElement) | undefined;
88
+ getExportValue?: ((item: TeamMember) => string) | undefined;
89
+ }>;
90
+ deleteButtonText?: string | undefined;
91
+ singularName?: string | undefined;
92
+ pluralName?: string | undefined;
93
+ isDeleteable?: boolean | undefined;
94
+ };
95
+
96
+ let capturedTableProps: CapturedTableProps | null = null;
97
+
98
+ jest.mock("../../../UI/Components/ModelTable/ModelTable", () => {
99
+ return {
100
+ __esModule: true,
101
+ default: (props: CapturedTableProps) => {
102
+ capturedTableProps = props;
103
+ return null;
104
+ },
105
+ };
106
+ });
107
+
108
+ import Users from "../../../../App/FeatureSet/Dashboard/src/Pages/Users/Index";
109
+ import ProjectUsersModelAPI from "../../../UI/Utils/ModelAPI/ProjectUsersModelAPI";
110
+ import TeamMembersByUser, {
111
+ ProjectUserRow,
112
+ } from "../../../UI/Utils/TeamMembersByUser";
113
+ import ModelAPI from "../../../UI/Utils/ModelAPI/ModelAPI";
114
+ import ProjectUtil from "../../../UI/Utils/Project";
115
+ import Project from "../../../Models/DatabaseModels/Project";
116
+ import Team from "../../../Models/DatabaseModels/Team";
117
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
118
+ import User from "../../../Models/DatabaseModels/User";
119
+ import Route from "../../../Types/API/Route";
120
+ import Name from "../../../Types/Name";
121
+ import ObjectID from "../../../Types/ObjectID";
122
+
123
+ const PROJECT_ID: ObjectID = new ObjectID(
124
+ "00000000-0000-4000-8000-000000000001",
125
+ );
126
+
127
+ const currentProject: Project = new Project();
128
+ currentProject._id = PROJECT_ID.toString();
129
+
130
+ type MembershipSpec = {
131
+ id: string;
132
+ userId: string;
133
+ userName?: string | undefined;
134
+ teamId?: string | undefined;
135
+ teamName?: string | undefined;
136
+ hasAcceptedInvitation?: boolean | undefined;
137
+ };
138
+
139
+ const buildMembership: (spec: MembershipSpec) => TeamMember = (
140
+ spec: MembershipSpec,
141
+ ): TeamMember => {
142
+ const teamMember: TeamMember = new TeamMember();
143
+
144
+ teamMember._id = spec.id;
145
+ teamMember.userId = new ObjectID(spec.userId);
146
+ teamMember.projectId = PROJECT_ID;
147
+ teamMember.hasAcceptedInvitation = spec.hasAcceptedInvitation ?? true;
148
+
149
+ const user: User = new User();
150
+ user._id = spec.userId;
151
+
152
+ if (spec.userName) {
153
+ user.name = new Name(spec.userName);
154
+ }
155
+
156
+ teamMember.user = user;
157
+
158
+ if (spec.teamId) {
159
+ teamMember.teamId = new ObjectID(spec.teamId);
160
+
161
+ const team: Team = new Team();
162
+ team._id = spec.teamId;
163
+ team.name = spec.teamName || spec.teamId;
164
+ teamMember.team = team;
165
+ }
166
+
167
+ return teamMember;
168
+ };
169
+
170
+ /*
171
+ * A row exactly as ProjectUsersModelAPI hands it to the table, built by the
172
+ * real grouping code rather than hand-written, so a change in the shape of a
173
+ * grouped row cannot leave these assertions passing against a shape the page
174
+ * never actually receives.
175
+ */
176
+ const buildGroupedRow: (
177
+ memberships: Array<MembershipSpec>,
178
+ ) => ProjectUserRow = (memberships: Array<MembershipSpec>): ProjectUserRow => {
179
+ return TeamMembersByUser.groupByUser(memberships.map(buildMembership))[0]!;
180
+ };
181
+
182
+ type ColumnOf = (title: string) => CapturedTableProps["columns"][0];
183
+
184
+ const columnTitled: ColumnOf = (
185
+ title: string,
186
+ ): CapturedTableProps["columns"][0] => {
187
+ const column: CapturedTableProps["columns"][0] | undefined =
188
+ capturedTableProps?.columns.find(
189
+ (candidate: CapturedTableProps["columns"][0]) => {
190
+ return candidate.title === title;
191
+ },
192
+ );
193
+
194
+ if (!column) {
195
+ throw new Error(
196
+ `No "${title}" column. Columns: ${(capturedTableProps?.columns || [])
197
+ .map((candidate: CapturedTableProps["columns"][0]) => {
198
+ return candidate.title;
199
+ })
200
+ .join(", ")}`,
201
+ );
202
+ }
203
+
204
+ return column;
205
+ };
206
+
207
+ type RenderCellFunction = (title: string, row: ProjectUserRow) => void;
208
+
209
+ const renderCell: RenderCellFunction = (
210
+ title: string,
211
+ row: ProjectUserRow,
212
+ ): void => {
213
+ render(<MemoryRouter>{columnTitled(title).getElement!(row)}</MemoryRouter>);
214
+ };
215
+
216
+ describe("Settings > Users page", () => {
217
+ beforeEach(async () => {
218
+ capturedTableProps = null;
219
+
220
+ jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
221
+ // The page counts SCIM configs on mount to decide whether to warn.
222
+ jest.spyOn(ModelAPI, "count").mockResolvedValue(0);
223
+ jest.spyOn(ModelAPI, "getCommonHeaders").mockReturnValue({});
224
+
225
+ render(
226
+ <MemoryRouter>
227
+ <Users
228
+ pageRoute={new Route("/dashboard/users")}
229
+ currentProject={currentProject}
230
+ hasPaymentMethod={true}
231
+ />
232
+ </MemoryRouter>,
233
+ );
234
+
235
+ await waitFor(() => {
236
+ expect(capturedTableProps).not.toBeNull();
237
+ });
238
+ });
239
+
240
+ afterEach(() => {
241
+ jest.restoreAllMocks();
242
+ });
243
+
244
+ describe("the fix itself", () => {
245
+ /*
246
+ * If this fails, every user on more than one team is back to occupying one
247
+ * row per team - which is the bug customers reported.
248
+ */
249
+ test("lists people, not memberships, by using the grouping API", () => {
250
+ expect(capturedTableProps!.modelAPI).toBe(ProjectUsersModelAPI);
251
+ });
252
+
253
+ test("names its rows after people", () => {
254
+ expect(capturedTableProps!.singularName).toBe("User");
255
+ expect(capturedTableProps!.pluralName).toBe("Users");
256
+ });
257
+ });
258
+
259
+ describe("the Teams column", () => {
260
+ test("shows every team the person belongs to, not just one", () => {
261
+ renderCell(
262
+ "Teams",
263
+ buildGroupedRow([
264
+ {
265
+ id: "m1",
266
+ userId: "u1",
267
+ userName: "Thomas Stock",
268
+ teamId: "t1",
269
+ teamName: "NTW_Online Operations",
270
+ },
271
+ {
272
+ id: "m2",
273
+ userId: "u1",
274
+ userName: "Thomas Stock",
275
+ teamId: "t2",
276
+ teamName: "Owners",
277
+ },
278
+ ]),
279
+ );
280
+
281
+ expect(screen.getByText("NTW_Online Operations")).toBeInTheDocument();
282
+ expect(screen.getByText("Owners")).toBeInTheDocument();
283
+ });
284
+
285
+ test("collapses a long team list behind a 'more' control rather than growing the row", () => {
286
+ renderCell(
287
+ "Teams",
288
+ buildGroupedRow([
289
+ { id: "m1", userId: "u1", teamId: "t1", teamName: "Alpha" },
290
+ { id: "m2", userId: "u1", teamId: "t2", teamName: "Bravo" },
291
+ { id: "m3", userId: "u1", teamId: "t3", teamName: "Charlie" },
292
+ { id: "m4", userId: "u1", teamId: "t4", teamName: "Delta" },
293
+ { id: "m5", userId: "u1", teamId: "t5", teamName: "Echo" },
294
+ ]),
295
+ );
296
+
297
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
298
+ expect(screen.queryByText("Echo")).not.toBeInTheDocument();
299
+ expect(screen.getByText("2 more teams")).toBeInTheDocument();
300
+ });
301
+
302
+ test("says so when the person is on no team at all", () => {
303
+ renderCell(
304
+ "Teams",
305
+ buildGroupedRow([{ id: "m1", userId: "u1", userName: "Nobody" }]),
306
+ );
307
+
308
+ expect(screen.getByText("No team assigned")).toBeInTheDocument();
309
+ });
310
+
311
+ /*
312
+ * The exporter is column-driven and would otherwise fall back to the
313
+ * single `team` on the membership the row was built from, so the CSV would
314
+ * quietly disagree with the cell beside it.
315
+ */
316
+ test("exports every team, not the one the row was built from", () => {
317
+ const row: ProjectUserRow = buildGroupedRow([
318
+ { id: "m1", userId: "u1", teamId: "t1", teamName: "Engineering" },
319
+ { id: "m2", userId: "u1", teamId: "t2", teamName: "On-Call" },
320
+ { id: "m3", userId: "u1", teamId: "t3", teamName: "Admins" },
321
+ ]);
322
+
323
+ expect(columnTitled("Teams").getExportValue!(row)).toBe(
324
+ "Admins; Engineering; On-Call",
325
+ );
326
+ });
327
+
328
+ test("exports an empty cell rather than crashing on a team-less row", () => {
329
+ const row: ProjectUserRow = buildGroupedRow([
330
+ { id: "m1", userId: "u1", userName: "Nobody" },
331
+ ]);
332
+
333
+ expect(columnTitled("Teams").getExportValue!(row)).toBe("");
334
+ });
335
+ });
336
+
337
+ describe("the Status column", () => {
338
+ test("reads Member once any invitation has been accepted", () => {
339
+ renderCell(
340
+ "Status",
341
+ buildGroupedRow([
342
+ {
343
+ id: "m1",
344
+ userId: "u1",
345
+ teamId: "t1",
346
+ hasAcceptedInvitation: true,
347
+ },
348
+ ]),
349
+ );
350
+
351
+ expect(screen.getByText("Member")).toBeInTheDocument();
352
+ expect(screen.queryByText(/Pending/)).not.toBeInTheDocument();
353
+ });
354
+
355
+ test("reads Invitation Sent while nothing has been accepted", () => {
356
+ renderCell(
357
+ "Status",
358
+ buildGroupedRow([
359
+ {
360
+ id: "m1",
361
+ userId: "u1",
362
+ teamId: "t1",
363
+ hasAcceptedInvitation: false,
364
+ },
365
+ {
366
+ id: "m2",
367
+ userId: "u1",
368
+ teamId: "t2",
369
+ hasAcceptedInvitation: false,
370
+ },
371
+ ]),
372
+ );
373
+
374
+ expect(screen.getByText("Invitation Sent")).toBeInTheDocument();
375
+ expect(screen.queryByText("Member")).not.toBeInTheDocument();
376
+ });
377
+
378
+ /*
379
+ * Per-team invitation state used to be visible because each row WAS one
380
+ * team. On a grouped row it would disappear entirely without this.
381
+ */
382
+ test("still surfaces invitations the person has not accepted yet", () => {
383
+ renderCell(
384
+ "Status",
385
+ buildGroupedRow([
386
+ {
387
+ id: "m1",
388
+ userId: "u1",
389
+ teamId: "t1",
390
+ hasAcceptedInvitation: true,
391
+ },
392
+ {
393
+ id: "m2",
394
+ userId: "u1",
395
+ teamId: "t2",
396
+ hasAcceptedInvitation: false,
397
+ },
398
+ {
399
+ id: "m3",
400
+ userId: "u1",
401
+ teamId: "t3",
402
+ hasAcceptedInvitation: false,
403
+ },
404
+ ]),
405
+ );
406
+
407
+ expect(screen.getByText("Member")).toBeInTheDocument();
408
+ expect(screen.getByText("2 Invitations Pending")).toBeInTheDocument();
409
+ });
410
+
411
+ test("counts a single pending invitation in the singular", () => {
412
+ renderCell(
413
+ "Status",
414
+ buildGroupedRow([
415
+ {
416
+ id: "m1",
417
+ userId: "u1",
418
+ teamId: "t1",
419
+ hasAcceptedInvitation: true,
420
+ },
421
+ {
422
+ id: "m2",
423
+ userId: "u1",
424
+ teamId: "t2",
425
+ hasAcceptedInvitation: false,
426
+ },
427
+ ]),
428
+ );
429
+
430
+ expect(screen.getByText("1 Invitation Pending")).toBeInTheDocument();
431
+ });
432
+
433
+ /*
434
+ * The server can only order by the stored per-membership column, which on
435
+ * a grouped row disagrees with the derived one on screen - so the control
436
+ * has to be off rather than visibly not sorting.
437
+ */
438
+ test("offers no sort control, because the value is derived", () => {
439
+ expect(columnTitled("Status").disableSort).toBe(true);
440
+ });
441
+ });
442
+
443
+ describe("removing someone", () => {
444
+ test("labels the action as removal from the project, not from a team", () => {
445
+ expect(capturedTableProps!.deleteButtonText).toBe("Remove from Project");
446
+ });
447
+ });
448
+ });